mirror of
https://github.com/kuaifan/dootask.git
synced 2026-05-24 01:14:06 +00:00
feat(multi-owner): 群/项目/部门支持主+副双负责人体系
- 群组:新增 web_socket_dialog_users.role(1=主、2=副),主可任命/罢免副群主,副可邀请/移出普通成员 - 项目:project_users.owner 扩展为 0/1/2(成员/主/副),主独占转让和删除,副共享日常管理;任务可见性、通知、分配等下游逻辑统一用「主+副」 - 部门:新增 user_department_owners 表存储副负责人;部门群同步副群主,赋予群管理员权限 - 转移用户时副身份不替补、降级为普通成员 - 配套 migration/backfill、API、前端 UI、i18n 词条与三项 Feature 测试 - .gitignore 忽略 .playwright-mcp/
This commit is contained in:
parent
2a3f05e06f
commit
24710289e1
3
.gitignore
vendored
3
.gitignore
vendored
@ -61,3 +61,6 @@ laravels.pid
|
||||
|
||||
# Documentation
|
||||
README_LOCAL.md
|
||||
|
||||
# playwright
|
||||
.playwright-mcp/
|
||||
|
||||
@ -1755,8 +1755,10 @@ class DialogController extends AbstractController
|
||||
}
|
||||
// 任务可见性校验(与 task__one 一致)
|
||||
if ($task->visibility != 1) {
|
||||
$project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid');
|
||||
if ($user->userid != $project_userid) {
|
||||
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->map(fn($v) => (int)$v)->toArray();
|
||||
if (!in_array($user->userid, $projectOwnerids)) {
|
||||
$visibleUserids = array_merge(
|
||||
ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(),
|
||||
ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(),
|
||||
@ -2832,7 +2834,10 @@ class DialogController extends AbstractController
|
||||
return Base::retError('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003);
|
||||
}
|
||||
} else {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
if (!$dialog->isOwner(User::userid())) {
|
||||
throw new \App\Exceptions\ApiException('仅群主或群管理员可操作');
|
||||
}
|
||||
}
|
||||
//
|
||||
$data = ['id' => $dialog->id];
|
||||
@ -2891,7 +2896,11 @@ class DialogController extends AbstractController
|
||||
return Base::retError('请选择群成员');
|
||||
}
|
||||
//
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, "auto");
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
// 有群主(主或副)时,仅群主/副群主可邀请;无群主时,任意成员可邀请
|
||||
if ($dialog->owner_id > 0 && !$dialog->isOwner($user->userid)) {
|
||||
throw new \App\Exceptions\ApiException('仅限群主或群管理员操作');
|
||||
}
|
||||
//
|
||||
$dialog->checkGroup();
|
||||
$dialog->joinGroup($userids, $user->userid);
|
||||
@ -2981,17 +2990,107 @@ class DialogController extends AbstractController
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, $check_owner);
|
||||
//
|
||||
$dialog->checkGroup($check_owner ? 'user' : null);
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->owner_id = $userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($userid, 0);
|
||||
// 同步 role:原主 role=0、新主 role=1(覆盖即可)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->deputy_ids,
|
||||
]);
|
||||
}
|
||||
return Base::retSuccess('转让成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命副群主(仅主群主可操作)
|
||||
*
|
||||
* @apiParam {Number} dialog_id 群对话ID
|
||||
* @apiParam {Number} userid 要任命的群成员 userid
|
||||
*/
|
||||
public function group__adddeputy()
|
||||
{
|
||||
$user = User::auth();
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true:仅主群主
|
||||
$dialog->checkGroup('user'); // 仅普通群
|
||||
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return Base::retError('该用户不是群成员');
|
||||
}
|
||||
|
||||
if ((int)$member->role === 1) {
|
||||
return Base::retError('不能将群主任命为群管理员');
|
||||
}
|
||||
if ((int)$member->role !== 2) {
|
||||
$member->role = 2;
|
||||
$member->save();
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免副群主(仅主群主可操作)
|
||||
*
|
||||
* @apiParam {Number} dialog_id 群对话ID
|
||||
* @apiParam {Number} userid 要罢免的副群主 userid
|
||||
*/
|
||||
public function group__deldeputy()
|
||||
{
|
||||
$user = User::auth();
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
|
||||
$dialog->checkGroup('user');
|
||||
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员
|
||||
}
|
||||
|
||||
if ((int)$member->role === 2) {
|
||||
$member->role = 0;
|
||||
$member->save();
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/group/disband 解散群组
|
||||
*
|
||||
|
||||
@ -372,15 +372,16 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/user 修改项目成员
|
||||
* @api {post} api/project/user 修改项目成员
|
||||
*
|
||||
* @apiDescription 需要token身份(限:项目负责人)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName user
|
||||
*
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number} userid 成员ID 或 成员ID组
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number[]} userid 成员userid数组(最终完整列表)
|
||||
* @apiParam {Number[]} [deputy_userid] 副负责人userid数组(可选,仅主负责人有效;必须是 userid 子集)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -393,6 +394,13 @@ class ProjectController extends AbstractController
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$userid = Request::input('userid');
|
||||
$userid = is_array($userid) ? $userid : [$userid];
|
||||
$userid = array_values(array_unique(array_map('intval', $userid)));
|
||||
//
|
||||
$deputy_userid = Request::input('deputy_userid');
|
||||
if ($deputy_userid !== null) {
|
||||
$deputy_userid = is_array($deputy_userid) ? $deputy_userid : [$deputy_userid];
|
||||
$deputy_userid = array_values(array_unique(array_map('intval', $deputy_userid)));
|
||||
}
|
||||
//
|
||||
if (count($userid) > 100) {
|
||||
return Base::retError('项目人数最多100个');
|
||||
@ -400,7 +408,20 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$project = Project::userProject($project_id, true, true);
|
||||
//
|
||||
$deleteUser = AbstractModel::transaction(function() use ($project, $userid) {
|
||||
// 仅主负责人可设置副负责人;副负责人/其他角色提交 deputy_userid 一律忽略
|
||||
$isPrimary = (int)$project->owner === ProjectUser::OWNER_PRIMARY;
|
||||
$applyDeputy = $isPrimary && $deputy_userid !== null;
|
||||
//
|
||||
if ($applyDeputy) {
|
||||
if (!empty(array_diff($deputy_userid, $userid))) {
|
||||
return Base::retError('项目管理员必须是项目成员');
|
||||
}
|
||||
if (in_array((int)$project->owner_userid, $deputy_userid, true)) {
|
||||
return Base::retError('负责人不能任命为项目管理员');
|
||||
}
|
||||
}
|
||||
//
|
||||
$deleteUser = AbstractModel::transaction(function() use ($project, $userid, $applyDeputy, $deputy_userid) {
|
||||
$array = [];
|
||||
foreach ($userid as $uid) {
|
||||
if ($project->joinProject($uid)) {
|
||||
@ -408,15 +429,37 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
}
|
||||
$deleteRows = ProjectUser::whereProjectId($project->id)->whereNotIn('userid', $array)->get();
|
||||
$deleteUser = $deleteRows->pluck('userid');
|
||||
$deleteUserids = $deleteRows->pluck('userid');
|
||||
foreach ($deleteRows as $row) {
|
||||
$row->exitProject();
|
||||
}
|
||||
//
|
||||
// 副负责人 diff(仅主负责人有效)
|
||||
if ($applyDeputy) {
|
||||
$currentDeputies = ProjectUser::whereProjectId($project->id)
|
||||
->where('owner', ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')->toArray();
|
||||
$toPromote = array_values(array_diff($deputy_userid, $currentDeputies));
|
||||
$toDemote = array_values(array_diff($currentDeputies, $deputy_userid));
|
||||
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->syncDialogUser();
|
||||
$project->addLog("修改项目成员");
|
||||
$project->user_simple = count($array) . "|" . implode(",", array_slice($array, 0, 3));
|
||||
$project->save();
|
||||
return $deleteUser->toArray();
|
||||
return $deleteUserids->toArray();
|
||||
});
|
||||
//
|
||||
$project->pushMsg('delete', null, $deleteUser);
|
||||
@ -574,28 +617,138 @@ class ProjectController extends AbstractController
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$owner_userid = intval(Request::input('owner_userid'));
|
||||
//
|
||||
$project = Project::userProject($project_id, true, true);
|
||||
$project = Project::userProject($project_id, true, 'primary');
|
||||
//
|
||||
if (!User::whereUserid($owner_userid)->exists()) {
|
||||
return Base::retError('成员不存在');
|
||||
}
|
||||
//
|
||||
AbstractModel::transaction(function() use ($owner_userid, $project) {
|
||||
ProjectUser::whereProjectId($project->id)->change(['owner' => 0]);
|
||||
// 仅清除原主 owner=1(副 owner=2 保留)
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->change(['owner' => 0]);
|
||||
// 设新主 owner=1(如新主原本是副,从 2 升为 1)
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $owner_userid,
|
||||
], [
|
||||
'owner' => 1,
|
||||
'owner' => ProjectUser::OWNER_PRIMARY,
|
||||
]);
|
||||
// 同步项目群 owner_id
|
||||
if ($project->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($project->dialog_id);
|
||||
if ($dialog) {
|
||||
$dialog->owner_id = $owner_userid;
|
||||
$dialog->save();
|
||||
}
|
||||
}
|
||||
// 同步成员 + role(syncDialogUser 已根据 owner 设置 role)
|
||||
$project->syncDialogUser();
|
||||
$project->addLog("移交项目给", ['userid' => $owner_userid]);
|
||||
});
|
||||
//
|
||||
$project->pushMsg('detail');
|
||||
// pushMsg 带 deputy_userids,前端可直接更新副列表无需重拉
|
||||
$project->pushMsg('detail', [
|
||||
'owner_userid' => $project->fresh()->owner_userid,
|
||||
'deputy_userids' => $project->fresh()->deputy_userids,
|
||||
]);
|
||||
return Base::retSuccess('移交成功', ['id' => $project->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/adddeputy 任命副负责人(仅主负责人可操作)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName adddeputy
|
||||
*
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number} userid 要任命的项目成员 userid
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function adddeputy()
|
||||
{
|
||||
User::auth();
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$project = Project::userProject($project_id, true, 'primary');
|
||||
|
||||
$member = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $userid)->first();
|
||||
if (!$member) {
|
||||
return Base::retError('该用户不是项目成员');
|
||||
}
|
||||
if ((int)$member->owner === ProjectUser::OWNER_PRIMARY) {
|
||||
return Base::retError('不能将负责人任命为项目管理员');
|
||||
}
|
||||
if ((int)$member->owner !== ProjectUser::OWNER_DEPUTY) {
|
||||
AbstractModel::transaction(function() use ($project, $member) {
|
||||
$member->owner = ProjectUser::OWNER_DEPUTY;
|
||||
$member->save();
|
||||
$project->syncDialogUser(); // 同步群 role
|
||||
$project->addLog('任命项目管理员', ['userid' => $member->userid]);
|
||||
});
|
||||
$project->pushMsg('detail', [
|
||||
'deputy_userids' => $project->fresh()->deputy_userids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/deldeputy 罢免副负责人(仅主负责人可操作)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName deldeputy
|
||||
*
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number} userid 要罢免的副负责人 userid
|
||||
*/
|
||||
public function deldeputy()
|
||||
{
|
||||
User::auth();
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$project = Project::userProject($project_id, true, 'primary');
|
||||
|
||||
$member = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $userid)->first();
|
||||
if (!$member) {
|
||||
return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员
|
||||
}
|
||||
if ((int)$member->owner === ProjectUser::OWNER_DEPUTY) {
|
||||
AbstractModel::transaction(function() use ($project, $member) {
|
||||
$member->owner = ProjectUser::OWNER_MEMBER;
|
||||
$member->save();
|
||||
$project->syncDialogUser();
|
||||
$project->addLog('罢免项目管理员', ['userid' => $member->userid]);
|
||||
});
|
||||
$project->pushMsg('detail', [
|
||||
'deputy_userids' => $project->fresh()->deputy_userids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/sort 排序任务
|
||||
*
|
||||
@ -784,7 +937,7 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
//
|
||||
$project = Project::userProject($project_id, null, true);
|
||||
$project = Project::userProject($project_id, null, 'primary');
|
||||
//
|
||||
$project->deleteProject();
|
||||
return Base::retSuccess('删除成功', ['id' => $project->id]);
|
||||
@ -1194,7 +1347,7 @@ class ProjectController extends AbstractController
|
||||
// 任务可见性条件
|
||||
$builder->leftJoin('project_users', function ($query) use($userid) {
|
||||
$query->on('project_tasks.project_id', '=', 'project_users.project_id');
|
||||
$query->where('project_users.owner', 1);
|
||||
$query->whereIn('project_users.owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]);
|
||||
$query->where('project_users.userid', $userid);
|
||||
});
|
||||
$builder->leftJoin('project_task_visibility_users', function ($query) use($userid) {
|
||||
@ -1839,8 +1992,10 @@ class ProjectController extends AbstractController
|
||||
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived);
|
||||
$task = ProjectTask::userTask($task_id, $isArchived, true, ['taskUser', 'taskTag']);
|
||||
// 项目可见性
|
||||
$project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid'); // 项目负责人
|
||||
if ($task->visibility != 1 && $user->userid != $project_userid) {
|
||||
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->map(fn($v) => (int)$v)->toArray(); // 项目负责人(主+副)
|
||||
if ($task->visibility != 1 && !in_array($user->userid, $projectOwnerids)) {
|
||||
$taskUserids = ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(); //任务负责人、协助人
|
||||
$subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人
|
||||
$visibleUserids = ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray(); //可见人
|
||||
@ -2347,7 +2502,9 @@ class ProjectController extends AbstractController
|
||||
if ($data['visibility'] == 1) {
|
||||
$data['is_visible'] = 1;
|
||||
} else {
|
||||
$projectOwner = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
|
||||
$projectOwner = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray(); // 项目负责人(主+副)
|
||||
$taskOwnerAndAssists = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->pluck('userid')->toArray();
|
||||
$visibleIds = array_merge($projectOwner, $taskOwnerAndAssists);
|
||||
$data['is_visible'] = in_array($user->userid, $visibleIds) ? 1 : 0;
|
||||
@ -2398,7 +2555,10 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
$data = ProjectTask::oneTask($task->id);
|
||||
$pushUserIds = ProjectTaskUser::whereTaskId($task->id)->pluck('userid')->toArray();
|
||||
$pushUserIds[] = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid');
|
||||
$ownerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray();
|
||||
$pushUserIds = array_merge($pushUserIds, $ownerids);
|
||||
foreach ($pushUserIds as $userId) {
|
||||
$task->pushMsg('add', $data, $userId);
|
||||
}
|
||||
|
||||
@ -2145,6 +2145,65 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess($id > 0 ? '保存成功' : '新建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/department/adddeputy 任命副负责人(限管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName department__adddeputy
|
||||
*
|
||||
* @apiParam {Number} id 部门 id
|
||||
* @apiParam {Number} userid 副负责人 userid
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
*/
|
||||
public function department__adddeputy()
|
||||
{
|
||||
User::auth('admin');
|
||||
$id = intval(Request::input('id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
$dept = UserDepartment::find($id);
|
||||
if (empty($dept)) {
|
||||
return Base::retError('部门不存在或已被删除');
|
||||
}
|
||||
|
||||
// ApiException 由框架统一捕获并 retError 转换
|
||||
$dept->addDeputy($userid);
|
||||
|
||||
Cache::forever("UserDepartment::rand", Base::generatePassword());
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/department/deldeputy 罢免副负责人(限管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName department__deldeputy
|
||||
*
|
||||
* @apiParam {Number} id 部门 id
|
||||
* @apiParam {Number} userid 要罢免的副负责人 userid
|
||||
*/
|
||||
public function department__deldeputy()
|
||||
{
|
||||
User::auth('admin');
|
||||
$id = intval(Request::input('id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
$dept = UserDepartment::find($id);
|
||||
if (empty($dept)) {
|
||||
return Base::retError('部门不存在或已被删除');
|
||||
}
|
||||
|
||||
$dept->delDeputy($userid);
|
||||
Cache::forever("UserDepartment::rand", Base::generatePassword());
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/department/del 删除部门(限管理员)
|
||||
*
|
||||
|
||||
@ -77,6 +77,7 @@ class Project extends AbstractModel
|
||||
|
||||
protected $appends = [
|
||||
'owner_userid',
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -92,6 +93,58 @@ class Project extends AbstractModel
|
||||
return $this->appendattrs['owner_userid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 副负责人 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否主负责人(与 project_users.owner=1 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否副负责人(与 project_users.owner=2 一致)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(主或副)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
@ -227,21 +280,40 @@ class Project extends AbstractModel
|
||||
return;
|
||||
}
|
||||
AbstractModel::transaction(function() {
|
||||
$userids = $this->relationUserids();
|
||||
// 拉所有项目成员 + 各自 owner 值
|
||||
$userOwnerMap = ProjectUser::whereProjectId($this->id)
|
||||
->pluck('owner', 'userid');
|
||||
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
|
||||
foreach ($userids as $userid) {
|
||||
$owner = (int)$userOwnerMap[$userid];
|
||||
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
|
||||
$role = $owner;
|
||||
WebSocketDialogUser::updateInsert([
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'userid' => $userid,
|
||||
], [
|
||||
'important' => 1
|
||||
], function () use ($userid) {
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
], function () use ($userid, $role) {
|
||||
return [
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
'bot' => User::isBot($userid) ? 1 : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)
|
||||
->whereNotIn('userid', $userids)
|
||||
->whereImportant(1)
|
||||
->remove();
|
||||
// 同步 dialog.owner_id 到主负责人(owner=1):前端「群主」标签依赖此字段,
|
||||
// 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户
|
||||
$primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY);
|
||||
if ($primaryUserid !== false && (int)$primaryUserid > 0) {
|
||||
WebSocketDialog::whereId($this->dialog_id)
|
||||
->where('owner_id', '!=', (int)$primaryUserid)
|
||||
->update(['owner_id' => (int)$primaryUserid]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -378,7 +450,7 @@ class Project extends AbstractModel
|
||||
// 处理所有者权限
|
||||
if (isset($data['owner'])) {
|
||||
$owners = ProjectUser::whereProjectId($data['id'])
|
||||
->whereOwner(1)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$recipients = [
|
||||
@ -599,7 +671,7 @@ class Project extends AbstractModel
|
||||
$column['project_id'] = $project->id;
|
||||
ProjectColumn::createInstance($column)->save();
|
||||
}
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid);
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException('创建项目聊天室失败');
|
||||
}
|
||||
@ -621,7 +693,9 @@ class Project extends AbstractModel
|
||||
* 获取项目信息(用于判断会员是否存在项目内)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
|
||||
* @param null|bool|string $mustOwner true:主或副都可(主+副共享操作);
|
||||
* 'primary':仅主(转让/删除/任命副等主独占操作);
|
||||
* false:仅限非负责人;null:不限制
|
||||
* @return self
|
||||
*/
|
||||
public static function userProject($project_id, $archived = true, $mustOwner = null)
|
||||
@ -639,6 +713,9 @@ class Project extends AbstractModel
|
||||
if ($mustOwner === true && !$project->owner) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === false && $project->owner) {
|
||||
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
|
||||
@ -1991,7 +1991,9 @@ class ProjectTask extends AbstractModel
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
//
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray(); // 项目负责人(主+副)
|
||||
//
|
||||
$array = [];
|
||||
if (empty($userids)) {
|
||||
|
||||
@ -37,6 +37,36 @@ use App\Module\Base;
|
||||
*/
|
||||
class ProjectUser extends AbstractModel
|
||||
{
|
||||
/** @var int 普通成员编码 */
|
||||
const OWNER_MEMBER = 0;
|
||||
/** @var int 主负责人编码 */
|
||||
const OWNER_PRIMARY = 1;
|
||||
/** @var int 副负责人编码 */
|
||||
const OWNER_DEPUTY = 2;
|
||||
|
||||
/**
|
||||
* 是否主负责人(owner=1)
|
||||
*/
|
||||
public function isPrimaryOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_PRIMARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否副负责人(owner=2)
|
||||
*/
|
||||
public function isDeputyOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_DEPUTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(主或副)
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->isPrimaryOwner() || $this->isDeputyOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
@ -61,12 +91,19 @@ class ProjectUser extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
|
||||
if ($row) {
|
||||
// 已存在则删除原数据,判断改变已存在的数据
|
||||
$row->owner = max($row->owner, $item->owner);
|
||||
// 已存在:仅当离职用户是主(owner=1)时把接收人升为主;
|
||||
// 离职用户是副(owner=2)时不传副给接收人(spec:副不替补)
|
||||
if ((int)$item->owner === self::OWNER_PRIMARY) {
|
||||
$row->owner = self::OWNER_PRIMARY;
|
||||
}
|
||||
// owner=2/0:保留接收人原有 owner 值不变
|
||||
$row->save();
|
||||
$item->delete();
|
||||
} else {
|
||||
// 不存在则改变原数据
|
||||
// 不存在:转移时如果离职用户是副,降级为普通成员(不带副身份过户给接收人)
|
||||
if ((int)$item->owner === self::OWNER_DEPUTY) {
|
||||
$item->owner = self::OWNER_MEMBER;
|
||||
}
|
||||
$item->userid = $newUserid;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
@ -35,6 +35,10 @@ use Cache;
|
||||
*/
|
||||
class UserDepartment extends AbstractModel
|
||||
{
|
||||
protected $appends = [
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取所有父级部门
|
||||
* @return array
|
||||
@ -50,6 +54,55 @@ class UserDepartment extends AbstractModel
|
||||
return $parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 副负责人 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否主负责人(与 owner_userid 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int)$this->owner_userid === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否副负责人(在 user_department_owners 表里)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(主或副)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存部门
|
||||
* @param $data
|
||||
@ -69,14 +122,25 @@ class UserDepartment extends AbstractModel
|
||||
// 已有群
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原主 role=0、新主 role=1(副 role=2 保留不动)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -86,16 +150,33 @@ class UserDepartment extends AbstractModel
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException("选择现有聊天群不存在");
|
||||
}
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
$dialog->group_type = 'department';
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原主 role=0、新主 role=1、原副 role=0
|
||||
// 原副清零:避免 dialog_users.role=2 与 user_department_owners 不一致
|
||||
// (副关系不带过来,须通过 addDeputy 显式重新任命)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', '!=', $this->owner_userid)
|
||||
->where('role', 2)
|
||||
->update(['role' => 0]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'group_type' => $dialog->group_type,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [
|
||||
'notice' => User::nickname() . " 将此群改为部门群"
|
||||
@ -116,6 +197,12 @@ class UserDepartment extends AbstractModel
|
||||
$oldUser->department = array_diff($oldUser->department, [$this->id]);
|
||||
$oldUser->department = "," . implode(",", $oldUser->department) . ",";
|
||||
$oldUser->save();
|
||||
// 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致)
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->exitGroup($oldUser->userid, 'remove', false, true);
|
||||
}
|
||||
}
|
||||
if ($newUser) {
|
||||
$newUser->department = array_diff($newUser->department, [$this->id]);
|
||||
@ -126,6 +213,112 @@ class UserDepartment extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命副负责人
|
||||
* - 副自动加入 users.department(成为部门成员,与主对齐)
|
||||
* - 副自动加入部门群 + 设 role=2
|
||||
* - 幂等(已是副不报错)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function addDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
throw new ApiException('请选择有效的成员');
|
||||
}
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if (!$user) {
|
||||
throw new ApiException('该用户不存在');
|
||||
}
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
throw new ApiException('不能将部门负责人任命为部门管理员');
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid, $user) {
|
||||
// 写副表(unique key 自动幂等)
|
||||
\DB::table('user_department_owners')->insertOrIgnore([
|
||||
'department_id' => $this->id,
|
||||
'userid' => $userid,
|
||||
]);
|
||||
|
||||
// 加入 users.department(成为部门成员,与主对齐)
|
||||
$userDeptIds = $user->department; // accessor 返回数组
|
||||
if (!in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_merge($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 加副入部门群 + 设 role=2
|
||||
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);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 2]);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免副负责人
|
||||
* - 删副表记录
|
||||
* - 从 users.department 移除该部门 ID(与主"离开部门"对齐)
|
||||
* - 退出部门群(成员关系=群关系一致)
|
||||
* - 幂等
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
*/
|
||||
public function delDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid) {
|
||||
$deleted = \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
// 从 users.department 移除该部门 ID
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if ($user) {
|
||||
$userDeptIds = $user->department;
|
||||
if (in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_diff($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
||||
// 退出部门群(exitGroup 会清除 dialog_users 记录,role 随之消失)
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
$dialog->exitGroup($userid, 'remove', false, true);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @return void
|
||||
@ -148,6 +341,8 @@ class UserDepartment extends AbstractModel
|
||||
// 解散群组
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->deleteDialog();
|
||||
// 清理副负责人记录(防悬挂)
|
||||
\DB::table('user_department_owners')->where('department_id', $this->id)->delete();
|
||||
//
|
||||
$this->delete();
|
||||
}
|
||||
@ -160,6 +355,7 @@ class UserDepartment extends AbstractModel
|
||||
*/
|
||||
public static function transfer($originalUserid, $newUserid)
|
||||
{
|
||||
// 主转让(保持现有逻辑)
|
||||
self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
|
||||
/** @var self $item */
|
||||
foreach ($list as $item) {
|
||||
@ -168,6 +364,11 @@ class UserDepartment extends AbstractModel
|
||||
]);
|
||||
}
|
||||
});
|
||||
// 副离职清理(新增):直接删除离职用户的所有副记录
|
||||
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群,role 随成员关系一起消失
|
||||
\DB::table('user_department_owners')
|
||||
->where('userid', $originalUserid)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -62,6 +62,8 @@ class WebSocketDialog extends AbstractModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $appends = ['deputy_ids'];
|
||||
|
||||
/**
|
||||
* 头像地址
|
||||
* @param $value
|
||||
@ -457,11 +459,12 @@ class WebSocketDialog extends AbstractModel
|
||||
* @param int|array $userid 加入的会员ID或会员ID组
|
||||
* @param int $inviter 邀请人
|
||||
* @param bool|null $important 重要人员(null不修改、bool修改)
|
||||
* @param bool $pushMsg 是否推送消息
|
||||
* @return bool
|
||||
*/
|
||||
public function joinGroup($userid, $inviter, $important = null)
|
||||
public function joinGroup($userid, $inviter, $important = null, $pushMsg = true)
|
||||
{
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) {
|
||||
foreach (is_array($userid) ? $userid : [$userid] as $value) {
|
||||
if ($value > 0) {
|
||||
$updateData = [
|
||||
@ -479,7 +482,7 @@ class WebSocketDialog extends AbstractModel
|
||||
'bot' => User::isBot($value) ? 1 : 0
|
||||
]);
|
||||
}, $isInsert);
|
||||
if ($isInsert) {
|
||||
if ($isInsert && $pushMsg) {
|
||||
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
|
||||
'notice' => User::userid2nickname($value) . " 已加入群组"
|
||||
], $inviter, true, true);
|
||||
@ -487,9 +490,11 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
});
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -515,11 +520,27 @@ class WebSocketDialog extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
if ($checkDelete) {
|
||||
if ($type === 'remove') {
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群主/副群主/邀请人可以操作
|
||||
if ($this->group_type === 'all') {
|
||||
User::auth("admin");
|
||||
} elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
} else {
|
||||
$actor = User::userid();
|
||||
// 未认证时拒绝
|
||||
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) {
|
||||
throw new ApiException('群管理员不能移出群主或其他群管理员');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($item->userid == $this->owner_id) {
|
||||
@ -547,9 +568,11 @@ class WebSocketDialog extends AbstractModel
|
||||
});
|
||||
});
|
||||
//
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -635,6 +658,53 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否主群主(与 owner_id 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
return $userid > 0 && (int)$this->owner_id === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否副群主(仅 web_socket_dialog_users.role=2)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->where('role', 2)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(主或副)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 副群主 userid 列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyIdsAttribute(): array
|
||||
{
|
||||
if (!$this->id) {
|
||||
return [];
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查禁言
|
||||
* @param $userid
|
||||
@ -857,6 +927,7 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $value,
|
||||
'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0,
|
||||
'bot' => User::isBot($value) ? 1 : 0,
|
||||
'important' => !in_array($group_type, ['user', 'all']),
|
||||
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,
|
||||
|
||||
@ -113,7 +113,9 @@ class ProjectTaskObserver extends AbstractObserver
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)->pluck('userid')->toArray();
|
||||
}
|
||||
if (in_array('projectOwnerUser', $dataType)) {
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)->where('owner', 1)->pluck('userid')->toArray();
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray();
|
||||
}
|
||||
$array = [];
|
||||
if (in_array('task', $dataType)) {
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddRoleToWebSocketDialogUsers extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('web_socket_dialog_users', 'role')) {
|
||||
$table->tinyInteger('role')->default(0)->after('userid')
|
||||
->comment('0=普通成员 1=主群主 2=副群主');
|
||||
$table->index(['dialog_id', 'role'], 'idx_dialog_role');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('web_socket_dialog_users', 'role')) {
|
||||
$table->dropIndex('idx_dialog_role');
|
||||
$table->dropColumn('role');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillDialogOwnerRole extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
// 把每个群里 userid = web_socket_dialogs.owner_id 的成员记录设为 role=1(主群主)
|
||||
// 幂等:仅当 role=0 时才更新
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialog_users du
|
||||
INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id
|
||||
SET du.role = 1
|
||||
WHERE d.owner_id > 0
|
||||
AND du.userid = d.owner_id
|
||||
AND du.role = 0
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
// 回滚:把 role=1 的记录全部回到 role=0
|
||||
DB::statement("UPDATE {$prefix}web_socket_dialog_users SET role = 0 WHERE role = 1");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateUserDepartmentOwnersTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (!Schema::hasTable('user_department_owners')) {
|
||||
Schema::create('user_department_owners', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('department_id')->comment('部门ID');
|
||||
$table->unsignedBigInteger('userid')->comment('副负责人 userid');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->unique(['department_id', 'userid'], 'uniq_dept_user');
|
||||
$table->index('userid', 'idx_userid');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
if (Schema::hasTable('user_department_owners')) {
|
||||
Schema::dropIfExists('user_department_owners');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillProjectDialogPrimaryOwner extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 修复历史项目群聊未登记群主的问题:
|
||||
* - 早期 Project::addProject 调 createGroup 时未传 owner_id 第 4 参
|
||||
* (在 commit 3a9001e09 才补上),导致老项目群 dialogs.owner_id = 0
|
||||
* - 这些群也因此被 2026_04_30_000002_backfill_dialog_owner_role 跳过
|
||||
* (那条迁移要求 owner_id > 0),主负责人那条 dialog_users.role 仍为 0
|
||||
*
|
||||
* 本迁移仅处理 group_type = 'project' 且未软删的项目:
|
||||
* (a) dialogs.owner_id = 0 → 按 project_users.owner=1 回填
|
||||
* (b) 同一批群里主负责人那条 dialog_users.role = 0 → 设为 1
|
||||
*
|
||||
* 全部带幂等条件,可重跑。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
// (a) 回填 dialogs.owner_id
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialogs d
|
||||
INNER JOIN {$prefix}projects p ON p.dialog_id = d.id
|
||||
INNER JOIN {$prefix}project_users pu ON pu.project_id = p.id AND pu.owner = 1
|
||||
SET d.owner_id = pu.userid
|
||||
WHERE d.owner_id = 0
|
||||
AND d.group_type = 'project'
|
||||
AND p.deleted_at IS NULL
|
||||
");
|
||||
|
||||
// (b) 把这些项目群里主负责人那条 dialog_users.role 设为 1
|
||||
// 不依赖 (a) 的结果,直接按 project_users.owner=1 反查,幂等条件 du.role=0
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialog_users du
|
||||
INNER JOIN {$prefix}projects p ON p.dialog_id = du.dialog_id
|
||||
INNER JOIN {$prefix}project_users pu
|
||||
ON pu.project_id = p.id
|
||||
AND pu.userid = du.userid
|
||||
AND pu.owner = 1
|
||||
SET du.role = 1
|
||||
WHERE du.role = 0
|
||||
AND p.deleted_at IS NULL
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* 数据回填类迁移不提供精确回滚——回滚会丢失原本就正确的数据。
|
||||
* 如需重置,请手动操作。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@ -974,3 +974,21 @@ AI 返回内容为空
|
||||
没有权限操作此任务
|
||||
请选择要转发的消息
|
||||
LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
群管理员
|
||||
任命群管理员
|
||||
罢免群管理员
|
||||
该用户不是群成员
|
||||
不能将群主任命为群管理员
|
||||
仅群主或群管理员可操作
|
||||
仅限群主或群管理员操作
|
||||
群管理员不能移出群主或其他群管理员
|
||||
请选择有效的成员
|
||||
任命成功
|
||||
罢免成功
|
||||
项目管理员
|
||||
任命项目管理员
|
||||
罢免项目管理员
|
||||
该用户不是项目成员
|
||||
不能将负责人任命为项目管理员
|
||||
不能将部门负责人任命为部门管理员
|
||||
该用户不存在
|
||||
|
||||
@ -2361,3 +2361,26 @@ AI任务分析
|
||||
登录属性
|
||||
用于匹配登录用户名的 LDAP 属性,Active Directory 请选择 sAMAccountName
|
||||
请输入帐号
|
||||
群管理员
|
||||
任命群管理员
|
||||
罢免群管理员
|
||||
确定要罢免该群管理员吗?
|
||||
还没有群管理员
|
||||
添加群管理员
|
||||
确定将 (*) 任命为群管理员吗?
|
||||
项目管理员
|
||||
任命项目管理员
|
||||
罢免项目管理员
|
||||
确定要罢免该项目管理员吗?
|
||||
还没有项目管理员
|
||||
添加项目管理员
|
||||
确定将 (*) 任命为项目管理员吗?
|
||||
部门管理员
|
||||
任命部门管理员
|
||||
罢免部门管理员
|
||||
请选择部门管理员
|
||||
确定将 (*) 任命为部门管理员吗?
|
||||
部门管理员享有部门群的群管理员权限
|
||||
即将罢免项目管理员
|
||||
请确认以下操作,注意此操作不可逆!
|
||||
移除成员负责的任务将变成无负责人。
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
</div>
|
||||
<div v-if="showName" class="avatar-name" :style="nameStyle">
|
||||
<div v-if="user.bot" class="taskfont bot"></div>
|
||||
<slot name="name-prefix"/>
|
||||
<span>{{nameText || user.nickname}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<div class="quick-edit">
|
||||
<div class="quick-text" :title="dialogData.name">{{dialogData.name}}</div>
|
||||
<Icon
|
||||
v-if="dialogData.owner_id == userId"
|
||||
v-if="isOwnerOrDeputy"
|
||||
class="quick-icon"
|
||||
type="ios-create-outline"
|
||||
@click.stop="onEditName"/>
|
||||
@ -39,31 +39,46 @@
|
||||
<span>{{$L('群机器人')}}</span>
|
||||
</li>
|
||||
<li v-for="item in botList" @click="openUser(item.userid)">
|
||||
<UserAvatar :userid="item.userid" :size="32" showName/>
|
||||
<div v-if="item.userid === dialogData.owner_id" class="user-tag">{{ $L("群主") }}</div>
|
||||
<div v-else-if="operableExit(item)" class="user-exit" @click.stop="onExit(item)"><Icon type="md-exit"/></div>
|
||||
<UserAvatar :userid="item.userid" :size="32" showName>
|
||||
<template v-if="item.userid === dialogData.owner_id" #name-prefix>
|
||||
<div class="user-tag">{{ $L("群主") }}</div>
|
||||
</template>
|
||||
<template v-else-if="(dialogData.deputy_ids || []).includes(item.userid)" #name-prefix>
|
||||
<div class="deputy-tag">{{ $L('群管理员') }}</div>
|
||||
</template>
|
||||
</UserAvatar>
|
||||
<div v-if="canKickMember(item)" class="user-exit" @click.stop="onExit(item)"><Icon type="md-exit"/></div>
|
||||
</li>
|
||||
<li class="label">
|
||||
<span>{{$L(`群成员 (${userList.length}人)`)}}</span>
|
||||
</li>
|
||||
<li v-for="item in userList" @click="openUser(item.userid)">
|
||||
<UserAvatar :userid="item.userid" :size="32" showName/>
|
||||
<div v-if="item.userid === dialogData.owner_id" class="user-tag">{{ $L("群主") }}</div>
|
||||
<div v-else-if="operableExit(item)" class="user-exit" @click.stop="onExit(item)"><Icon type="md-exit"/></div>
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li v-for="item in userList" @click="openUser(item.userid)">
|
||||
<UserAvatar :userid="item.userid" :size="32" showName/>
|
||||
<div v-if="item.userid === dialogData.owner_id" class="user-tag">{{ $L("群主") }}</div>
|
||||
<div v-else-if="operableExit(item)" class="user-exit" @click.stop="onExit(item)"><Icon type="md-exit"/></div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-for="item in userList" @click="openUser(item.userid)">
|
||||
<UserAvatar :userid="item.userid" :size="32" showName>
|
||||
<template v-if="item.userid === dialogData.owner_id" #name-prefix>
|
||||
<div class="user-tag">{{ $L("群主") }}</div>
|
||||
</template>
|
||||
<template v-else-if="(dialogData.deputy_ids || []).includes(item.userid)" #name-prefix>
|
||||
<div class="deputy-tag">{{ $L('群管理员') }}</div>
|
||||
</template>
|
||||
</UserAvatar>
|
||||
<div
|
||||
v-if="canManageDeputy && !isPrimaryOwner(item) && !isDeputy(item)"
|
||||
class="user-deputy-add"
|
||||
:title="$L('任命群管理员')"
|
||||
@click.stop="addDeputy(item)"><Icon type="md-add"/></div>
|
||||
<div
|
||||
v-if="canManageDeputy && isDeputy(item)"
|
||||
class="user-deputy-del"
|
||||
:title="$L('罢免群管理员')"
|
||||
@click.stop="delDeputy(item)"><Icon type="md-remove"/></div>
|
||||
<div v-if="canKickMember(item)" class="user-exit" @click.stop="onExit(item)"><Icon type="md-exit"/></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="operableAdd" class="group-info-button">
|
||||
<Button v-if="dialogData.owner_id == userId || dialogData.owner_id == 0" @click="openAdd" type="primary" icon="md-add">{{ $L("添加成员") }}</Button>
|
||||
<Button v-if="isOwnerOrDeputy || dialogData.owner_id == 0" @click="openAdd" type="primary" icon="md-add">{{ $L("添加成员") }}</Button>
|
||||
</div>
|
||||
|
||||
<!--添加成员-->
|
||||
@ -152,10 +167,15 @@ export default {
|
||||
}
|
||||
return true;
|
||||
})
|
||||
const deputyIds = dialogData.deputy_ids || [];
|
||||
const rank = uid => {
|
||||
if (uid === dialogData.owner_id) return 0;
|
||||
if (deputyIds.includes(uid)) return 1;
|
||||
return 2;
|
||||
};
|
||||
return list.sort((a, b) => {
|
||||
if (a.userid === dialogData.owner_id || b.userid === dialogData.owner_id) {
|
||||
return (a.userid === dialogData.owner_id ? 0 : 1) - (b.userid === dialogData.owner_id ? 0 : 1);
|
||||
}
|
||||
const ra = rank(a.userid), rb = rank(b.userid);
|
||||
if (ra !== rb) return ra - rb;
|
||||
return $A.sortDay(a.created_at, b.created_at);
|
||||
})
|
||||
},
|
||||
@ -168,6 +188,17 @@ export default {
|
||||
userList({allList}) {
|
||||
return allList.filter(item => !item.bot)
|
||||
},
|
||||
|
||||
canManageDeputy() {
|
||||
// Only the primary owner can manage deputies
|
||||
return this.dialogData?.owner_id === this.userId;
|
||||
},
|
||||
|
||||
isOwnerOrDeputy() {
|
||||
if (!this.dialogData) return false;
|
||||
if (this.dialogData.owner_id === this.userId) return true;
|
||||
return (this.dialogData.deputy_ids || []).includes(this.userId);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -214,7 +245,7 @@ export default {
|
||||
if (group_type == 'all') {
|
||||
return this.userIsAdmin
|
||||
}
|
||||
return [0, this.userId].includes(owner_id)
|
||||
return [0, this.userId].includes(owner_id) || this.isOwnerOrDeputy
|
||||
},
|
||||
|
||||
openAdd() {
|
||||
@ -243,12 +274,25 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
operableExit(item) {
|
||||
const {owner_id, group_type} = this.dialogData
|
||||
if (group_type == 'all') {
|
||||
return this.userIsAdmin
|
||||
}
|
||||
return owner_id == this.userId || item.inviter == this.userId
|
||||
isPrimaryOwner(item) {
|
||||
return item.userid === this.dialogData.owner_id;
|
||||
},
|
||||
|
||||
isDeputy(item) {
|
||||
return (this.dialogData.deputy_ids || []).includes(item.userid);
|
||||
},
|
||||
|
||||
canKickMember(item) {
|
||||
if (!this.dialogData) return false;
|
||||
if (item.userid === this.userId) return false; // can't kick self via this button
|
||||
const ownerId = this.dialogData.owner_id;
|
||||
const deputyIds = this.dialogData.deputy_ids || [];
|
||||
const isPrimary = ownerId === this.userId;
|
||||
const isDeputy = deputyIds.includes(this.userId);
|
||||
if (!isPrimary && !isDeputy) return false; // not a manager
|
||||
if (isPrimary) return item.userid !== ownerId; // primary can kick anyone except self
|
||||
// deputy: can't kick primary or other deputies
|
||||
return item.userid !== ownerId && !deputyIds.includes(item.userid);
|
||||
},
|
||||
|
||||
onExit(item) {
|
||||
@ -285,6 +329,52 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
addDeputy(item) {
|
||||
$A.modalConfirm({
|
||||
language: false,
|
||||
title: this.$L('任命群管理员'),
|
||||
content: this.$L('确定将 (*) 任命为群管理员吗?', item.nickname || item.email),
|
||||
onOk: () => {
|
||||
this.$store.dispatch('call', {
|
||||
url: 'dialog/group/adddeputy',
|
||||
data: {
|
||||
dialog_id: this.dialogData.id,
|
||||
userid: item.userid,
|
||||
},
|
||||
method: 'post',
|
||||
}).then(({msg}) => {
|
||||
$A.messageSuccess(msg);
|
||||
this.getDialogUser();
|
||||
}).catch(({msg}) => {
|
||||
$A.messageError(msg);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
delDeputy(item) {
|
||||
$A.modalConfirm({
|
||||
title: '罢免群管理员',
|
||||
content: '确定要罢免该群管理员吗?',
|
||||
// title/content auto-translated by modalConfig
|
||||
onOk: () => {
|
||||
this.$store.dispatch('call', {
|
||||
url: 'dialog/group/deldeputy',
|
||||
data: {
|
||||
dialog_id: this.dialogData.id,
|
||||
userid: item.userid,
|
||||
},
|
||||
method: 'post',
|
||||
}).then(({msg}) => {
|
||||
$A.messageSuccess(msg);
|
||||
this.getDialogUser();
|
||||
}).catch(({msg}) => {
|
||||
$A.messageError(msg);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openUser(userid) {
|
||||
if (this.openIng) {
|
||||
return
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<div v-if="loading" class="project-load"><Loading/></div>
|
||||
</div>
|
||||
<ul class="project-icons">
|
||||
<li class="project-avatar" :class="{'cursor-default': projectData.owner_userid !== userId}" @click="projectDropdown('user')">
|
||||
<li class="project-avatar" :class="{'cursor-default': !isOwnerOrDeputy}" @click="projectDropdown('user')">
|
||||
<ul>
|
||||
<li>
|
||||
<UserAvatarTip :userid="projectData.owner_userid" :size="36" :borderWidth="2" :openDelay="0">
|
||||
@ -24,8 +24,10 @@
|
||||
<Icon type="ios-more"/>
|
||||
</ETooltip>
|
||||
</li>
|
||||
<li v-else>
|
||||
<UserAvatarTip :userid="item.userid" :size="36" :borderWidth="2" :openDelay="0"/>
|
||||
<li v-else :class="{'is-deputy': isDeputyUid(item.userid)}">
|
||||
<UserAvatarTip :userid="item.userid" :size="36" :borderWidth="2" :openDelay="0">
|
||||
<p v-if="isDeputyUid(item.userid)">{{$L('项目管理员')}}</p>
|
||||
</UserAvatarTip>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
@ -50,7 +52,7 @@
|
||||
<li class="project-icon">
|
||||
<EDropdown @command="projectDropdown" trigger="click" transfer>
|
||||
<Icon class="menu-icon" type="ios-more" />
|
||||
<EDropdownMenu v-if="projectData.owner_userid === userId" slot="dropdown" class="project-panel-project-menu-dropdown">
|
||||
<EDropdownMenu v-if="isOwnerOrDeputy" slot="dropdown" class="project-panel-project-menu-dropdown">
|
||||
<EDropdownItem command="setting">{{$L('项目设置')}}</EDropdownItem>
|
||||
<EDropdownItem command="permissions">{{$L('权限设置')}}</EDropdownItem>
|
||||
<EDropdownItem command="task_template">{{$L('任务模板')}}</EDropdownItem>
|
||||
@ -62,9 +64,12 @@
|
||||
<EDropdownItem command="log">{{$L('项目动态')}}</EDropdownItem>
|
||||
<EDropdownItem command="archived_task">{{$L('已归档任务')}}</EDropdownItem>
|
||||
<EDropdownItem command="deleted_task">{{$L('已删除任务')}}</EDropdownItem>
|
||||
<EDropdownItem command="transfer" divided>{{$L('移交项目')}}</EDropdownItem>
|
||||
<EDropdownItem command="archived">{{$L('归档项目')}}</EDropdownItem>
|
||||
<EDropdownItem command="delete" style="color:#f40">{{$L('删除项目')}}</EDropdownItem>
|
||||
<!--主独占(仅主负责人可见)-->
|
||||
<template v-if="canManageDeputy">
|
||||
<EDropdownItem command="transfer" divided>{{$L('移交项目')}}</EDropdownItem>
|
||||
<EDropdownItem command="delete" style="color:#f40">{{$L('删除项目')}}</EDropdownItem>
|
||||
</template>
|
||||
</EDropdownMenu>
|
||||
<EDropdownMenu v-else slot="dropdown">
|
||||
<EDropdownItem command="task_tag">{{$L('任务标签')}}</EDropdownItem>
|
||||
@ -446,14 +451,27 @@
|
||||
:title="$L('成员管理')"
|
||||
:mask-closable="false">
|
||||
<Form :model="userData" v-bind="formOptions" @submit.native.prevent>
|
||||
<FormItem v-if="canManageDeputy" prop="deputy_userids" :label="$L('项目管理员')">
|
||||
<UserSelect
|
||||
v-model="userData.deputy_userids"
|
||||
:uncancelable="deputyRowUncancelable"
|
||||
:disabledChoice="deputyRowDisabledChoice"
|
||||
:multiple="true"
|
||||
:multiple-max="20"
|
||||
:title="$L('选择项目管理员')"/>
|
||||
</FormItem>
|
||||
<FormItem prop="userids" :label="$L('项目成员')">
|
||||
<UserSelect v-model="userData.userids" :uncancelable="userData.uncancelable" :multiple-max="100" :title="$L('选择项目成员')"/>
|
||||
<UserSelect
|
||||
v-model="userData.userids"
|
||||
:uncancelable="memberRowUncancelable"
|
||||
:multiple-max="100"
|
||||
:title="$L('选择项目成员')"/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<div slot="footer" class="adaption">
|
||||
<Button type="default" @click="userShow=false">{{$L('取消')}}</Button>
|
||||
<Poptip
|
||||
v-if="userWaitRemove.length > 0"
|
||||
v-if="userWaitRemove.length > 0 || deputyWaitDemote.length > 0"
|
||||
confirm
|
||||
placement="bottom"
|
||||
style="margin-left:8px"
|
||||
@ -462,11 +480,19 @@
|
||||
@on-ok="onUser"
|
||||
transfer>
|
||||
<div slot="title">
|
||||
<p><strong>{{$L('移除成员负责的任务将变成无负责人,')}}</strong></p>
|
||||
<p>{{$L('注意此操作不可逆!')}}</p>
|
||||
<ul class="project-panel-wait-remove">
|
||||
<li>{{$L('即将移除')}}:</li>
|
||||
<li v-for="id in userWaitRemove" :key="id">
|
||||
<p><strong>{{$L('请确认以下操作,注意此操作不可逆!')}}</strong></p>
|
||||
<template v-if="userWaitRemove.length > 0">
|
||||
<p>{{$L('移除成员负责的任务将变成无负责人。')}}</p>
|
||||
<ul class="project-panel-wait-remove">
|
||||
<li>{{$L('即将移除')}}:</li>
|
||||
<li v-for="id in userWaitRemove" :key="'r'+id">
|
||||
<UserAvatar :userid="id" :size="20" showName/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<ul v-if="deputyWaitDemote.length > 0" class="project-panel-wait-remove">
|
||||
<li>{{$L('即将罢免项目管理员')}}:</li>
|
||||
<li v-for="id in deputyWaitDemote" :key="'d'+id">
|
||||
<UserAvatar :userid="id" :size="20" showName/>
|
||||
</li>
|
||||
</ul>
|
||||
@ -747,6 +773,12 @@ export default {
|
||||
return wait;
|
||||
},
|
||||
|
||||
deputyWaitDemote() {
|
||||
// 所有从副负责人列表中移出的人(即使同时被踢出项目,也在罢免段显示,避免操作隐身)
|
||||
const {deputy_userids = [], deputy_useridbak = []} = this.userData;
|
||||
return deputy_useridbak.filter(id => !deputy_userids.includes(id));
|
||||
},
|
||||
|
||||
msgUnread() {
|
||||
const {cacheDialogs, projectData} = this;
|
||||
const dialog = cacheDialogs.find(({id}) => id === projectData.dialog_id);
|
||||
@ -779,19 +811,65 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
canManageDeputy() {
|
||||
return this.projectData?.owner_userid === this.userId;
|
||||
},
|
||||
|
||||
isOwnerOrDeputy() {
|
||||
if (!this.projectData) return false;
|
||||
if (this.projectData.owner_userid === this.userId) return true;
|
||||
return (this.projectData.deputy_userids || []).includes(this.userId);
|
||||
},
|
||||
|
||||
memberRowUncancelable() {
|
||||
// 项目成员行:主+当前副选择(响应式)都不可移除
|
||||
if (!this.projectData) return [];
|
||||
const deputies = (this.userData && Array.isArray(this.userData.deputy_userids))
|
||||
? this.userData.deputy_userids
|
||||
: (this.projectData.deputy_userids || []);
|
||||
return [
|
||||
this.projectData.owner_userid,
|
||||
...deputies,
|
||||
];
|
||||
},
|
||||
|
||||
deputyRowUncancelable() {
|
||||
// 副负责人行:防御性锁定主(理论上主不会出现在该 v-model 里)
|
||||
if (!this.projectData) return [];
|
||||
return [this.projectData.owner_userid];
|
||||
},
|
||||
|
||||
deputyRowDisabledChoice() {
|
||||
// 副负责人候选:排除主(不能任命主为副)
|
||||
if (!this.projectData) return [];
|
||||
return [this.projectData.owner_userid];
|
||||
},
|
||||
|
||||
projectMemberUserids() {
|
||||
return (this.projectData.project_user || []).map(({userid}) => userid);
|
||||
},
|
||||
|
||||
projectUser() {
|
||||
const {projectData, windowWidth} = this;
|
||||
if (!projectData.project_user) {
|
||||
return [];
|
||||
}
|
||||
let max = windowWidth > 1200 ? 8 : 3
|
||||
let list = projectData.project_user.filter(({userid}) => userid != projectData.owner_userid)
|
||||
const max = windowWidth > 1200 ? 8 : 3;
|
||||
const deputyIds = projectData.deputy_userids || [];
|
||||
const list = projectData.project_user
|
||||
.filter(({userid}) => userid != projectData.owner_userid)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const aD = deputyIds.includes(a.userid) ? 0 : 1;
|
||||
const bD = deputyIds.includes(b.userid) ? 0 : 1;
|
||||
return aD - bD;
|
||||
});
|
||||
if (list.length <= max) {
|
||||
return list
|
||||
return list;
|
||||
}
|
||||
let array = list.slice(0, max - 1);
|
||||
array.push({userid: -1})
|
||||
array.push(list[list.length - 1])
|
||||
const array = list.slice(0, max - 1);
|
||||
array.push({userid: -1});
|
||||
array.push(list[list.length - 1]);
|
||||
return array;
|
||||
},
|
||||
|
||||
@ -1090,6 +1168,16 @@ export default {
|
||||
windowWidth() {
|
||||
this.handleColumnDebounce(100);
|
||||
},
|
||||
'userData.deputy_userids'(newDeputies) {
|
||||
// 副负责人必须是项目成员:副行新增时自动并入成员行(罢免时不联动移除)
|
||||
if (!Array.isArray(newDeputies) || !Array.isArray(this.userData.userids)) {
|
||||
return;
|
||||
}
|
||||
const toAdd = newDeputies.filter(id => !this.userData.userids.includes(id));
|
||||
if (toAdd.length > 0) {
|
||||
this.userData.userids = [...this.userData.userids, ...toAdd];
|
||||
}
|
||||
},
|
||||
projectData(newData, oldData) {
|
||||
this.sortData = this.getSort();
|
||||
if (newData && newData.id && (!oldData || newData.id !== oldData.id)) {
|
||||
@ -1419,12 +1507,23 @@ export default {
|
||||
|
||||
onUser() {
|
||||
this.userLoad++;
|
||||
// 副负责人必须是项目成员:把 deputy 并入 userid 列表(前端归一化)
|
||||
const baseUserids = (this.userData.userids || []).slice();
|
||||
const deputyUserids = (this.userData.deputy_userids || []).slice();
|
||||
const mergedUserids = Array.from(new Set([...baseUserids, ...deputyUserids]));
|
||||
//
|
||||
const payload = {
|
||||
project_id: this.projectId,
|
||||
userid: mergedUserids,
|
||||
};
|
||||
// 仅主负责人发送 deputy_userid;副负责人/其他角色不发送(后端也会忽略)
|
||||
if (this.canManageDeputy) {
|
||||
payload.deputy_userid = deputyUserids;
|
||||
}
|
||||
//
|
||||
this.$store.dispatch("call", {
|
||||
url: 'project/user',
|
||||
data: {
|
||||
project_id: this.projectId,
|
||||
userid: this.userData.userids,
|
||||
},
|
||||
data: payload,
|
||||
}).then(({msg}) => {
|
||||
$A.messageSuccess(msg);
|
||||
this.userShow = false;
|
||||
@ -1534,13 +1633,15 @@ export default {
|
||||
break;
|
||||
|
||||
case "user":
|
||||
if (this.projectData.owner_userid !== this.userId) {
|
||||
if (!this.isOwnerOrDeputy) {
|
||||
return;
|
||||
}
|
||||
const userids = this.projectData.project_user.map(({userid}) => userid);
|
||||
const deputyUserids = [...(this.projectData.deputy_userids || [])];
|
||||
this.$set(this.userData, 'userids', userids);
|
||||
this.$set(this.userData, 'useridbak', userids);
|
||||
this.$set(this.userData, 'uncancelable', [this.projectData.owner_userid]);
|
||||
this.$set(this.userData, 'deputy_userids', deputyUserids);
|
||||
this.$set(this.userData, 'deputy_useridbak', deputyUserids);
|
||||
this.userShow = true;
|
||||
break;
|
||||
|
||||
@ -1593,6 +1694,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
isPrimaryOwnerUid(userid) {
|
||||
return userid === this.projectData.owner_userid;
|
||||
},
|
||||
|
||||
isDeputyUid(userid) {
|
||||
return (this.projectData.deputy_userids || []).includes(userid);
|
||||
},
|
||||
|
||||
openTask(task, receive) {
|
||||
this.$store.dispatch("openTask", task)
|
||||
if (receive === true) {
|
||||
|
||||
@ -37,9 +37,21 @@
|
||||
:key="item.id"
|
||||
:class="[`level-${item.level}`, departmentSelect === item.id || departmentOperation === item.id ? 'active' : '']"
|
||||
@click="onSelectDepartment(item.id)">
|
||||
<UserAvatarTip :userid="item.owner_userid" :size="20" class="department-icon">
|
||||
<p><strong>{{$L('部门负责人')}}</strong></p>
|
||||
</UserAvatarTip>
|
||||
<div class="department-owner-wrap">
|
||||
<template v-if="item.owner_userid > 0">
|
||||
<UserAvatarTip :userid="item.owner_userid" :size="20" class="department-icon">
|
||||
<p><strong>{{$L('部门负责人')}}</strong></p>
|
||||
</UserAvatarTip>
|
||||
<div v-if="(item.deputy_userids || []).length > 0" class="department-owner-more">+{{ item.deputy_userids.length }}</div>
|
||||
</template>
|
||||
<template v-else-if="(item.deputy_userids || []).length > 0">
|
||||
<UserAvatarTip :userid="item.deputy_userids[0]" :size="20" class="department-icon">
|
||||
<p>{{$L('部门管理员')}}</p>
|
||||
</UserAvatarTip>
|
||||
<div v-if="item.deputy_userids.length > 1" class="department-owner-more">+{{ item.deputy_userids.length - 1 }}</div>
|
||||
</template>
|
||||
<UserAvatarTip v-else :userid="0" :size="20" class="department-icon"/>
|
||||
</div>
|
||||
<div class="department-title">{{item.name}}</div>
|
||||
<EDropdown
|
||||
size="medium"
|
||||
@ -209,6 +221,15 @@
|
||||
<FormItem prop="owner_userid" :label="$L('部门负责人')">
|
||||
<UserSelect v-model="departmentData.owner_userid" :multiple-max="1" :title="$L('请选择部门负责人')"/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('部门管理员')">
|
||||
<UserSelect
|
||||
v-model="departmentData.deputy_userids"
|
||||
:multiple="true"
|
||||
:multiple-max="20"
|
||||
:disabled-choice="deputyDisabledChoice"
|
||||
:title="$L('请选择部门管理员')"/>
|
||||
<div class="form-tip">{{$L('部门管理员享有部门群的群管理员权限')}}</div>
|
||||
</FormItem>
|
||||
<template v-if="departmentData.id == 0">
|
||||
<Divider orientation="left">{{$L('群组设置')}}</Divider>
|
||||
<FormItem prop="dialog_group" :label="$L('部门群聊')">
|
||||
@ -690,6 +711,7 @@ export default {
|
||||
name: '',
|
||||
parent_id: 0,
|
||||
owner_userid: [],
|
||||
deputy_userids: [],
|
||||
dialog_group: 'new',
|
||||
dialog_useid: 0
|
||||
},
|
||||
@ -808,6 +830,13 @@ export default {
|
||||
style.minWidth = (minWidth - 40) + 'px'
|
||||
}
|
||||
return style
|
||||
},
|
||||
|
||||
deputyDisabledChoice() {
|
||||
// 主负责人不能同时是副;已是副的不需要再选
|
||||
return [
|
||||
...(this.departmentData.owner_userid || []),
|
||||
];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -1032,28 +1061,83 @@ export default {
|
||||
name: '',
|
||||
parent_id: 0,
|
||||
owner_userid: [],
|
||||
deputy_userids: [],
|
||||
dialog_group: 'new'
|
||||
}, data || {})
|
||||
// 编辑场景:将 owner_userid 转为数组(API 返回单值)
|
||||
if (this.departmentData.owner_userid && !Array.isArray(this.departmentData.owner_userid)) {
|
||||
this.departmentData.owner_userid = [this.departmentData.owner_userid];
|
||||
}
|
||||
// 编辑场景:deputy_userids 后端返回数组
|
||||
if (!Array.isArray(this.departmentData.deputy_userids)) {
|
||||
this.departmentData.deputy_userids = [];
|
||||
}
|
||||
this.departmentShow = true
|
||||
},
|
||||
|
||||
onSaveDepartment() {
|
||||
async onSaveDepartment() {
|
||||
this.departmentLoading++;
|
||||
this.$store.dispatch("call", {
|
||||
url: 'users/department/add',
|
||||
data: Object.assign(this.departmentData, {
|
||||
owner_userid: this.departmentData.owner_userid[0],
|
||||
}),
|
||||
}).then(({msg}) => {
|
||||
$A.messageSuccess(msg)
|
||||
this.getDepartmentLists()
|
||||
this.getLists()
|
||||
this.departmentShow = false
|
||||
}).catch(({msg}) => {
|
||||
try {
|
||||
// 先保存基本信息(保持现有行为)
|
||||
// store.dispatch("call",...) 成功时 resolve {data,msg,xhr}(无 ret 字段),失败时 reject
|
||||
const res = await this.$store.dispatch("call", {
|
||||
url: 'users/department/add',
|
||||
data: Object.assign({}, this.departmentData, {
|
||||
owner_userid: this.departmentData.owner_userid[0],
|
||||
}),
|
||||
});
|
||||
$A.messageSuccess(res.msg);
|
||||
|
||||
// 副列表同步(编辑/新建都支持)
|
||||
// 编辑场景:从 departmentList 取旧副;新建场景:从刚返回的列表反查刚创建的部门
|
||||
let targetId = this.departmentData.id;
|
||||
let oldDeputies = [];
|
||||
if (targetId > 0) {
|
||||
const oldList = this.departmentList.find(d => d.id === targetId);
|
||||
oldDeputies = (oldList && Array.isArray(oldList.deputy_userids)) ? oldList.deputy_userids : [];
|
||||
} else {
|
||||
// 新建:从最新部门列表中找刚创建的(按 name + parent_id 匹配,取最大 id 防同名旧部门)
|
||||
const list = await this.$store.dispatch('call', { url: 'users/department/list', method: 'get' });
|
||||
const matched = (list.data || [])
|
||||
.filter(d => d.name === this.departmentData.name && d.parent_id === this.departmentData.parent_id)
|
||||
.sort((a, b) => b.id - a.id);
|
||||
if (matched.length > 0) targetId = matched[0].id;
|
||||
}
|
||||
|
||||
if (targetId > 0) {
|
||||
const newDeputies = this.departmentData.deputy_userids || [];
|
||||
const toAdd = newDeputies.filter(uid => !oldDeputies.includes(uid));
|
||||
const toDel = oldDeputies.filter(uid => !newDeputies.includes(uid));
|
||||
|
||||
const adds = toAdd.map(uid => this.$store.dispatch('call', {
|
||||
url: 'users/department/adddeputy',
|
||||
data: { id: targetId, userid: uid },
|
||||
method: 'post',
|
||||
}));
|
||||
const dels = toDel.map(uid => this.$store.dispatch('call', {
|
||||
url: 'users/department/deldeputy',
|
||||
data: { id: targetId, userid: uid },
|
||||
method: 'post',
|
||||
}));
|
||||
const results = await Promise.allSettled([...adds, ...dels]);
|
||||
const errors = results
|
||||
.filter(r => r.status === 'rejected')
|
||||
.map(r => (r.reason && r.reason.msg) || '部门管理员同步失败');
|
||||
if (errors.length > 0) {
|
||||
$A.modalError(errors[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
await this.getDepartmentLists();
|
||||
this.getLists();
|
||||
this.departmentShow = false;
|
||||
} catch (e) {
|
||||
const msg = (e && e.msg) || (e && e.message) || '保存失败';
|
||||
$A.modalError(msg);
|
||||
}).finally(_ => {
|
||||
} finally {
|
||||
this.departmentLoading--;
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onSelectDepartment(id) {
|
||||
|
||||
3
resources/assets/sass/dark.scss
vendored
3
resources/assets/sass/dark.scss
vendored
@ -323,7 +323,8 @@ body.dark-mode-reverse {
|
||||
.group-info-user {
|
||||
> ul {
|
||||
> li {
|
||||
.user-tag {
|
||||
.user-tag,
|
||||
.deputy-tag {
|
||||
color: #1c1917;
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,7 +63,9 @@
|
||||
&:hover {
|
||||
background-color: rgba($primary-desc-color, 0.1);
|
||||
|
||||
.user-exit {
|
||||
.user-exit,
|
||||
.user-deputy-add,
|
||||
.user-deputy-del {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
@ -102,19 +104,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.user-tag {
|
||||
margin-left: 4px;
|
||||
.user-tag,
|
||||
.deputy-tag {
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
transform: scale(0.9);
|
||||
transform-origin: right center;
|
||||
transform-origin: left center;
|
||||
color: #ffffff;
|
||||
background-color: $primary-color;
|
||||
}
|
||||
|
||||
.user-exit {
|
||||
.deputy-tag {
|
||||
background-color: #5a8dee;
|
||||
}
|
||||
|
||||
.user-exit,
|
||||
.user-deputy-add,
|
||||
.user-deputy-del {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@ -137,7 +147,7 @@
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
color: $primary-desc-color;
|
||||
border: 1px solid #dddddd;
|
||||
border-radius: 50%;
|
||||
@ -168,7 +178,9 @@ body.window-portrait {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
.user-exit {
|
||||
.user-exit,
|
||||
.user-deputy-add,
|
||||
.user-deputy-del {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@ -109,10 +109,27 @@
|
||||
&.level-4 {
|
||||
margin-left: 54px;
|
||||
}
|
||||
.department-owner-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.department-icon {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.department-owner-more {
|
||||
margin-left: -2px;
|
||||
margin-right: 4px;
|
||||
padding: 0 5px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
font-size: 11px;
|
||||
color: #ffffff;
|
||||
background-color: #5a8dee;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.department-title {
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
373
tests/Feature/MultiOwnerDepartmentTest.php
Normal file
373
tests/Feature/MultiOwnerDepartmentTest.php
Normal file
@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MultiOwnerDepartmentTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// 部门测试涉及 User::save()(更新 department 字段),会触发 UserObserver→Apps::dispatchUserHook→Ihttp 外部 HTTP 调用。
|
||||
// 该 HTTP 调用在测试环境下会因 parse_url 没有 query 段触发 PHP 警告(被 Laravel 错误处理器升级为 ErrorException)。
|
||||
// 测试这层不关心 hook 行为,直接清空 User 观察者避免触发。
|
||||
User::flushEventListeners();
|
||||
|
||||
// PHP 8 在加载 App\Module\Table\AbstractData 时会发 E_WARNING(__wakeup/__clone 私有),
|
||||
// 被 Laravel HandleExceptions 升级为 ErrorException 中断 addDeputy 等流程。
|
||||
// 通过在静默错误处理下提前 class_exists 触发一次加载,使后续路径不再触发。
|
||||
if (!class_exists(\App\Module\Table\OnlineData::class, false)) {
|
||||
$prev = set_error_handler(static function () { return true; });
|
||||
try {
|
||||
class_exists(\App\Module\Table\OnlineData::class);
|
||||
} finally {
|
||||
set_error_handler($prev);
|
||||
}
|
||||
}
|
||||
|
||||
// saveDepartment 内部会触发 WebSocketDialog::pushMsg → Task::deliver → app('swoole')->task(),
|
||||
// 测试环境无 Swoole 运行时;绑定一个最小 stub 让 Task::deliver 安全降级,仅验证 DB 状态。
|
||||
// OnlineData::live 也会读取 swoole 的 onlineDataTable,提供一个 fake table(get 始终返回 0)。
|
||||
if (!app()->bound('swoole')) {
|
||||
$fakeTable = new class {
|
||||
public function get($key) { return 0; }
|
||||
public function set($key, $value) { return true; }
|
||||
public function del($key) { return true; }
|
||||
public function exist($key) { return false; }
|
||||
public function incr($key, $col, $incrBy = 1) { return 1; }
|
||||
public function decr($key, $col, $decrBy = 1) { return 0; }
|
||||
};
|
||||
app()->instance('swoole', new class($fakeTable) {
|
||||
public $worker_id = 0;
|
||||
public $taskworker = false;
|
||||
public $setting = ['worker_num' => 1];
|
||||
public $onlineDataTable;
|
||||
public $globalDataTable;
|
||||
public function __construct($fakeTable) {
|
||||
$this->onlineDataTable = $fakeTable;
|
||||
$this->globalDataTable = $fakeTable;
|
||||
}
|
||||
public function task($task) { return false; }
|
||||
public function sendMessage($task, $workerId) { return false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(int $ownerUserid, string $name = null): UserDepartment
|
||||
{
|
||||
$name = $name ?? 'Dept_' . substr(md5(uniqid('', true)), 0, 6);
|
||||
$dept = UserDepartment::createInstance();
|
||||
$dept->saveDepartment([
|
||||
'name' => $name,
|
||||
'parent_id' => 0,
|
||||
'owner_userid' => $ownerUserid,
|
||||
]);
|
||||
return $dept->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 department__adddeputy(绕过 User::auth('admin') / HTTP)。
|
||||
* 直接调用 UserDepartment::addDeputy,确保 simulate 与真实接口一致。
|
||||
* 注:addDeputy 方法在 Task 5 才实现;本 simulate 在 Task 5 之前调用会报错。
|
||||
*/
|
||||
private function simulateAddDeputy(UserDepartment $dept, int $userid): void
|
||||
{
|
||||
$dept->addDeputy($userid);
|
||||
}
|
||||
|
||||
private function simulateDelDeputy(UserDepartment $dept, int $userid): void
|
||||
{
|
||||
$dept->delDeputy($userid);
|
||||
}
|
||||
|
||||
public function test_setup_works()
|
||||
{
|
||||
$owner = $this->makeUser('d1_owner@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
$this->assertEquals($owner->userid, $dept->owner_userid);
|
||||
$this->assertNotEmpty($dept->dialog_id);
|
||||
}
|
||||
|
||||
public function test_helpers_and_deputy_userids_accessor()
|
||||
{
|
||||
$owner = $this->makeUser('d3_o@test.local');
|
||||
$deputy = $this->makeUser('d3_d@test.local');
|
||||
$member = $this->makeUser('d3_m@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
// 手动插入副记录(addDeputy 在 Task 5 才实现)
|
||||
DB::table('user_department_owners')->insert([
|
||||
'department_id' => $dept->id,
|
||||
'userid' => $deputy->userid,
|
||||
]);
|
||||
|
||||
$dept = $dept->fresh();
|
||||
$this->assertTrue($dept->isPrimaryOwner($owner->userid));
|
||||
$this->assertFalse($dept->isPrimaryOwner($deputy->userid));
|
||||
$this->assertFalse($dept->isPrimaryOwner($member->userid));
|
||||
|
||||
$this->assertFalse($dept->isDeputyOwner($owner->userid));
|
||||
$this->assertTrue($dept->isDeputyOwner($deputy->userid));
|
||||
$this->assertFalse($dept->isDeputyOwner($member->userid));
|
||||
|
||||
$this->assertTrue($dept->isOwner($owner->userid));
|
||||
$this->assertTrue($dept->isOwner($deputy->userid));
|
||||
$this->assertFalse($dept->isOwner($member->userid));
|
||||
|
||||
$deputyIds = $dept->deputy_userids;
|
||||
$this->assertEquals([$deputy->userid], $deputyIds);
|
||||
|
||||
// 序列化后 API 响应应包含 deputy_userids
|
||||
$arr = $dept->toArray();
|
||||
$this->assertArrayHasKey('deputy_userids', $arr);
|
||||
$this->assertEquals([$deputy->userid], $arr['deputy_userids']);
|
||||
}
|
||||
|
||||
public function test_saveDepartment_owner_change_syncs_dialog_role()
|
||||
{
|
||||
$oldOwner = $this->makeUser('d4_old@test.local');
|
||||
$newOwner = $this->makeUser('d4_new@test.local');
|
||||
$dept = $this->makeDepartment($oldOwner->userid);
|
||||
|
||||
// 手动加 newOwner 入群(saveDepartment 之前他不在群里)
|
||||
// joinGroup($userid, $inviter, $important=null, $pushMsg=true) — pushMsg=false 跳过 Swoole
|
||||
$dialog = WebSocketDialog::find($dept->dialog_id);
|
||||
$dialog->joinGroup($newOwner->userid, 0, null, false);
|
||||
|
||||
// 转让主负责人
|
||||
$dept->saveDepartment([
|
||||
'name' => $dept->name,
|
||||
'parent_id' => $dept->parent_id,
|
||||
'owner_userid' => $newOwner->userid,
|
||||
]);
|
||||
|
||||
$oldRole = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
|
||||
->where('userid', $oldOwner->userid)->value('role');
|
||||
$newRole = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
|
||||
->where('userid', $newOwner->userid)->value('role');
|
||||
|
||||
$this->assertEquals(0, (int)$oldRole, '原主应降为普通成员');
|
||||
$this->assertEquals(1, (int)$newRole, '新主 role 应为 1');
|
||||
}
|
||||
|
||||
public function test_saveDepartment_owner_change_preserves_deputies()
|
||||
{
|
||||
$oldOwner = $this->makeUser('d4b_old@test.local');
|
||||
$newOwner = $this->makeUser('d4b_new@test.local');
|
||||
$deputy = $this->makeUser('d4b_dep@test.local');
|
||||
$dept = $this->makeDepartment($oldOwner->userid);
|
||||
|
||||
// 加 deputy 入群 + 副记录 + role=2(pushMsg=false 跳过 Swoole)
|
||||
$dialog = WebSocketDialog::find($dept->dialog_id);
|
||||
$dialog->joinGroup($deputy->userid, 0, null, false);
|
||||
DB::table('user_department_owners')->insert([
|
||||
'department_id' => $dept->id,
|
||||
'userid' => $deputy->userid,
|
||||
]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)->update(['role' => 2]);
|
||||
|
||||
// 加 newOwner 入群
|
||||
$dialog->joinGroup($newOwner->userid, 0, null, false);
|
||||
|
||||
// 转让
|
||||
$dept->saveDepartment([
|
||||
'name' => $dept->name,
|
||||
'parent_id' => $dept->parent_id,
|
||||
'owner_userid' => $newOwner->userid,
|
||||
]);
|
||||
|
||||
// 副表保留
|
||||
$this->assertContains($deputy->userid, $dept->fresh()->deputy_userids);
|
||||
// 副 role 保留
|
||||
$depRole = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
|
||||
->where('userid', $deputy->userid)->value('role');
|
||||
$this->assertEquals(2, (int)$depRole);
|
||||
}
|
||||
|
||||
public function test_addDeputy_creates_owner_record_and_joins_group_as_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('d5_o@test.local');
|
||||
$deputy = $this->makeUser('d5_d@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
$this->simulateAddDeputy($dept, $deputy->userid);
|
||||
|
||||
$dept = $dept->fresh();
|
||||
$this->assertContains($deputy->userid, $dept->deputy_userids);
|
||||
// 副已入群
|
||||
$exists = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
|
||||
->where('userid', $deputy->userid)->exists();
|
||||
$this->assertTrue($exists);
|
||||
// 副 role=2
|
||||
$role = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
|
||||
->where('userid', $deputy->userid)->value('role');
|
||||
$this->assertEquals(2, (int)$role);
|
||||
}
|
||||
|
||||
public function test_addDeputy_idempotent()
|
||||
{
|
||||
$owner = $this->makeUser('d5b_o@test.local');
|
||||
$deputy = $this->makeUser('d5b_d@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
$this->simulateAddDeputy($dept, $deputy->userid);
|
||||
$this->simulateAddDeputy($dept, $deputy->userid);
|
||||
|
||||
$count = DB::table('user_department_owners')
|
||||
->where('department_id', $dept->id)
|
||||
->where('userid', $deputy->userid)->count();
|
||||
$this->assertEquals(1, $count);
|
||||
}
|
||||
|
||||
public function test_addDeputy_rejects_primary_owner()
|
||||
{
|
||||
$owner = $this->makeUser('d5c_o@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
$this->expectException(\App\Exceptions\ApiException::class);
|
||||
$this->simulateAddDeputy($dept, $owner->userid);
|
||||
}
|
||||
|
||||
public function test_addDeputy_rejects_nonexistent_user()
|
||||
{
|
||||
$owner = $this->makeUser('d5d_o@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
$this->expectException(\App\Exceptions\ApiException::class);
|
||||
$this->simulateAddDeputy($dept, 99999);
|
||||
}
|
||||
|
||||
public function test_delDeputy_removes_owner_record_and_exits_department_group()
|
||||
{
|
||||
$owner = $this->makeUser('d6_o@test.local');
|
||||
$deputy = $this->makeUser('d6_d@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
$this->simulateAddDeputy($dept, $deputy->userid);
|
||||
|
||||
// 任命后副应该入 users.department 并加入部门群
|
||||
$this->assertContains($dept->id, User::find($deputy->userid)->department, '任命副后应加入 users.department');
|
||||
$this->assertTrue(
|
||||
WebSocketDialogUser::where('dialog_id', $dept->dialog_id)->where('userid', $deputy->userid)->exists(),
|
||||
'任命副后应加入部门群'
|
||||
);
|
||||
|
||||
$this->simulateDelDeputy($dept, $deputy->userid);
|
||||
|
||||
$dept = $dept->fresh();
|
||||
$this->assertNotContains($deputy->userid, $dept->deputy_userids);
|
||||
// 罢免后从 users.department 移除(与主"离开部门"对齐)
|
||||
$this->assertNotContains($dept->id, User::find($deputy->userid)->department, '罢免副后应从 users.department 移除');
|
||||
// 退出部门群(成员关系=群关系一致)
|
||||
$exists = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
|
||||
->where('userid', $deputy->userid)->exists();
|
||||
$this->assertFalse($exists, '罢免副后应退出部门群(成员关系=群关系)');
|
||||
}
|
||||
|
||||
public function test_delDeputy_idempotent_for_non_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('d6b_o@test.local');
|
||||
$member = $this->makeUser('d6b_m@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
|
||||
// member 不是副,调 delDeputy 不应抛错
|
||||
$this->simulateDelDeputy($dept, $member->userid);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_deleteDepartment_cleans_deputy_records()
|
||||
{
|
||||
$owner = $this->makeUser('d7_o@test.local');
|
||||
$deputy = $this->makeUser('d7_d@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
$this->simulateAddDeputy($dept, $deputy->userid);
|
||||
$deptId = $dept->id;
|
||||
|
||||
$dept->deleteDepartment();
|
||||
|
||||
$count = DB::table('user_department_owners')->where('department_id', $deptId)->count();
|
||||
$this->assertEquals(0, $count);
|
||||
}
|
||||
|
||||
public function test_user_transfer_clears_departing_deputy_role()
|
||||
{
|
||||
$owner = $this->makeUser('d7b_o@test.local');
|
||||
$departing = $this->makeUser('d7b_dep@test.local');
|
||||
$receiver = $this->makeUser('d7b_rec@test.local');
|
||||
$dept = $this->makeDepartment($owner->userid);
|
||||
$this->simulateAddDeputy($dept, $departing->userid);
|
||||
|
||||
UserDepartment::transfer($departing->userid, $receiver->userid);
|
||||
|
||||
// 离职的副记录已删
|
||||
$this->assertNotContains($departing->userid, $dept->fresh()->deputy_userids);
|
||||
// receiver 没有继承副身份
|
||||
$this->assertNotContains($receiver->userid, $dept->fresh()->deputy_userids);
|
||||
}
|
||||
|
||||
public function test_user_transfer_inherits_departing_primary()
|
||||
{
|
||||
// 主转让仍要把主权位传给接收人(保留现有行为)
|
||||
$departing = $this->makeUser('d7c_dep@test.local');
|
||||
$receiver = $this->makeUser('d7c_rec@test.local');
|
||||
$dept = $this->makeDepartment($departing->userid);
|
||||
|
||||
UserDepartment::transfer($departing->userid, $receiver->userid);
|
||||
|
||||
$this->assertEquals($receiver->userid, $dept->fresh()->owner_userid);
|
||||
}
|
||||
|
||||
public function test_deleteDepartment_recursively_cleans_child_deputies()
|
||||
{
|
||||
// 父部门 + 子部门各有副,删父部门时副记录应级联清理
|
||||
$owner = $this->makeUser('d7d_o@test.local');
|
||||
$deputyParent = $this->makeUser('d7d_dp@test.local');
|
||||
$deputyChild = $this->makeUser('d7d_dc@test.local');
|
||||
|
||||
$parent = $this->makeDepartment($owner->userid, 'ParentDept');
|
||||
$this->simulateAddDeputy($parent, $deputyParent->userid);
|
||||
|
||||
$child = UserDepartment::createInstance();
|
||||
$child->saveDepartment([
|
||||
'name' => 'ChildDept',
|
||||
'parent_id' => $parent->id,
|
||||
'owner_userid' => $owner->userid,
|
||||
]);
|
||||
$child = $child->fresh();
|
||||
$this->simulateAddDeputy($child, $deputyChild->userid);
|
||||
|
||||
$parentId = $parent->id;
|
||||
$childId = $child->id;
|
||||
$parent->deleteDepartment(); // 递归删子部门 + 清各自副
|
||||
|
||||
$this->assertEquals(0, DB::table('user_department_owners')->where('department_id', $parentId)->count());
|
||||
$this->assertEquals(0, DB::table('user_department_owners')->where('department_id', $childId)->count());
|
||||
}
|
||||
}
|
||||
513
tests/Feature/MultiOwnerGroupTest.php
Normal file
513
tests/Feature/MultiOwnerGroupTest.php
Normal file
@ -0,0 +1,513 @@
|
||||
<?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 MultiOwnerGroupTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private function makeUser(string $email): User
|
||||
{
|
||||
$user = User::createInstance([
|
||||
'email' => $email,
|
||||
'userimg' => '',
|
||||
'nickname' => 'TestUser',
|
||||
'profession' => '',
|
||||
'password' => md5('123456'),
|
||||
]);
|
||||
$user->save();
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function makeGroup(int $ownerUserid, array $memberUserids = []): WebSocketDialog
|
||||
{
|
||||
$allMembers = array_unique(array_merge([$ownerUserid], $memberUserids));
|
||||
return WebSocketDialog::createGroup('TestGroup', $allMembers, 'user', $ownerUserid);
|
||||
}
|
||||
|
||||
public function test_isPrimaryOwner_returns_true_only_for_owner()
|
||||
{
|
||||
$owner = $this->makeUser('owner@test.local');
|
||||
$member = $this->makeUser('member@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
|
||||
|
||||
$this->assertTrue($dialog->isPrimaryOwner($owner->userid));
|
||||
$this->assertFalse($dialog->isPrimaryOwner($member->userid));
|
||||
}
|
||||
|
||||
public function test_isOwner_includes_primary_and_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('o2@test.local');
|
||||
$deputy = $this->makeUser('d2@test.local');
|
||||
$member = $this->makeUser('m2@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$deputy->userid, $member->userid]);
|
||||
|
||||
// 手工把 deputy 设为副群主
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)
|
||||
->update(['role' => 2]);
|
||||
|
||||
$this->assertTrue($dialog->isOwner($owner->userid));
|
||||
$this->assertTrue($dialog->isOwner($deputy->userid));
|
||||
$this->assertFalse($dialog->isOwner($member->userid));
|
||||
|
||||
$this->assertTrue($dialog->isDeputyOwner($deputy->userid));
|
||||
$this->assertFalse($dialog->isDeputyOwner($owner->userid));
|
||||
$this->assertFalse($dialog->isDeputyOwner($member->userid));
|
||||
}
|
||||
|
||||
public function test_deputy_ids_returns_array_of_role_2_userids()
|
||||
{
|
||||
$owner = $this->makeUser('o3@test.local');
|
||||
$d1 = $this->makeUser('d31@test.local');
|
||||
$d2 = $this->makeUser('d32@test.local');
|
||||
$m = $this->makeUser('m3@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$d1->userid, $d2->userid, $m->userid]);
|
||||
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->whereIn('userid', [$d1->userid, $d2->userid])
|
||||
->update(['role' => 2]);
|
||||
|
||||
$deputyIds = $dialog->deputy_ids;
|
||||
sort($deputyIds);
|
||||
$expected = [$d1->userid, $d2->userid];
|
||||
sort($expected);
|
||||
|
||||
$this->assertEquals($expected, $deputyIds);
|
||||
$this->assertNotContains($owner->userid, $deputyIds);
|
||||
$this->assertNotContains($m->userid, $deputyIds);
|
||||
}
|
||||
|
||||
public function test_createGroup_sets_owner_role_to_1()
|
||||
{
|
||||
$owner = $this->makeUser('o5@test.local');
|
||||
$m = $this->makeUser('m5@test.local');
|
||||
$dialog = WebSocketDialog::createGroup('G5', [$owner->userid, $m->userid], 'user', $owner->userid);
|
||||
|
||||
$ownerRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $owner->userid)
|
||||
->value('role');
|
||||
$memberRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $m->userid)
|
||||
->value('role');
|
||||
|
||||
$this->assertEquals(1, (int)$ownerRole);
|
||||
$this->assertEquals(0, (int)$memberRole);
|
||||
}
|
||||
|
||||
public function test_exitGroup_removes_deputy_role_along_with_membership()
|
||||
{
|
||||
$owner = $this->makeUser('o6@test.local');
|
||||
$deputy = $this->makeUser('d6@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$deputy->userid]);
|
||||
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)
|
||||
->update(['role' => 2]);
|
||||
|
||||
// 模拟副群主退群(pushMsg=false 跳过 Swoole 推送,仅验证 DB 状态)
|
||||
$dialog->exitGroup($deputy->userid, 'exit', false, false);
|
||||
|
||||
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)
|
||||
->exists();
|
||||
$this->assertFalse($exists, '退群后副群主记录应被删除');
|
||||
$this->assertNotContains($deputy->userid, $dialog->fresh()->deputy_ids);
|
||||
}
|
||||
|
||||
public function test_joinGroup_defaults_role_to_0()
|
||||
{
|
||||
$owner = $this->makeUser('o6b@test.local');
|
||||
$newbie = $this->makeUser('n6b@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid);
|
||||
|
||||
$dialog->joinGroup($newbie->userid, $owner->userid, null, false);
|
||||
|
||||
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $newbie->userid)
|
||||
->value('role');
|
||||
$this->assertEquals(0, (int)$role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接执行 group__transfer 的角色同步逻辑(model-level,绕过 HTTP/Swoole/Auth)。
|
||||
* HTTP 测试在此项目无法工作(User::auth() 依赖 Doo::userId() 与 RequestContext,
|
||||
* 不兼容 Laravel 标准 auth guard)。
|
||||
*/
|
||||
private function simulateTransfer(WebSocketDialog $dialog, int $newOwnerId): void
|
||||
{
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->owner_id = $newOwnerId;
|
||||
$dialog->save();
|
||||
|
||||
$dialog->joinGroup($newOwnerId, 0, null, false);
|
||||
|
||||
// 同步 role:原主 role=0、新主 role=1(覆盖即可)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== $newOwnerId) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $newOwnerId)
|
||||
->update(['role' => 1]);
|
||||
}
|
||||
|
||||
public function test_group_transfer_updates_role_for_old_and_new_owner()
|
||||
{
|
||||
$oldOwner = $this->makeUser('o7@test.local');
|
||||
$newOwner = $this->makeUser('n7@test.local');
|
||||
$dialog = $this->makeGroup($oldOwner->userid, [$newOwner->userid]);
|
||||
|
||||
$this->simulateTransfer($dialog, $newOwner->userid);
|
||||
|
||||
$oldRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwner->userid)->value('role');
|
||||
$newRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $newOwner->userid)->value('role');
|
||||
|
||||
$this->assertEquals(0, (int)$oldRole, '原主应降为普通成员');
|
||||
$this->assertEquals(1, (int)$newRole, '新主 role 应为 1');
|
||||
}
|
||||
|
||||
public function test_group_transfer_preserves_deputies()
|
||||
{
|
||||
$oldOwner = $this->makeUser('o7b@test.local');
|
||||
$newOwner = $this->makeUser('n7b@test.local');
|
||||
$deputy = $this->makeUser('d7b@test.local');
|
||||
$dialog = $this->makeGroup($oldOwner->userid, [$newOwner->userid, $deputy->userid]);
|
||||
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)->update(['role' => 2]);
|
||||
|
||||
$this->simulateTransfer($dialog, $newOwner->userid);
|
||||
|
||||
$this->assertContains($deputy->userid, $dialog->fresh()->deputy_ids);
|
||||
}
|
||||
|
||||
private function simulateAddDeputy(WebSocketDialog $dialog, int $userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return ['ret' => 0, 'msg' => '请选择有效的成员'];
|
||||
}
|
||||
// Note: skip checkDialog/checkGroup auth — assume caller is primary owner
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return ['ret' => 0, 'msg' => '该用户不是群成员'];
|
||||
}
|
||||
if ((int)$member->role === 1) {
|
||||
return ['ret' => 0, 'msg' => '不能将群主任命为群管理员'];
|
||||
}
|
||||
if ((int)$member->role !== 2) {
|
||||
$member->role = 2;
|
||||
$member->save();
|
||||
}
|
||||
return ['ret' => 1, 'msg' => '任命成功'];
|
||||
}
|
||||
|
||||
public function test_adddeputy_target_must_be_member()
|
||||
{
|
||||
$owner = $this->makeUser('o8b@test.local');
|
||||
$outsider = $this->makeUser('out8b@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid);
|
||||
|
||||
$result = $this->simulateAddDeputy($dialog, $outsider->userid);
|
||||
$this->assertEquals(0, $result['ret']);
|
||||
}
|
||||
|
||||
public function test_adddeputy_cannot_promote_primary_owner()
|
||||
{
|
||||
$owner = $this->makeUser('o8c@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid);
|
||||
|
||||
$result = $this->simulateAddDeputy($dialog, $owner->userid);
|
||||
$this->assertEquals(0, $result['ret']);
|
||||
}
|
||||
|
||||
public function test_adddeputy_sets_role_to_2_for_normal_member()
|
||||
{
|
||||
$owner = $this->makeUser('o8d@test.local');
|
||||
$member = $this->makeUser('m8d@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
|
||||
|
||||
$result = $this->simulateAddDeputy($dialog, $member->userid);
|
||||
$this->assertEquals(1, $result['ret']);
|
||||
|
||||
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $member->userid)->value('role');
|
||||
$this->assertEquals(2, (int)$role);
|
||||
$this->assertContains($member->userid, $dialog->fresh()->deputy_ids);
|
||||
}
|
||||
|
||||
public function test_adddeputy_idempotent()
|
||||
{
|
||||
$owner = $this->makeUser('o8e@test.local');
|
||||
$member = $this->makeUser('m8e@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
|
||||
|
||||
$this->simulateAddDeputy($dialog, $member->userid);
|
||||
$result = $this->simulateAddDeputy($dialog, $member->userid); // 第二次
|
||||
$this->assertEquals(1, $result['ret']);
|
||||
|
||||
// 应该只有一条 role=2 记录
|
||||
$count = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $member->userid)
|
||||
->where('role', 2)
|
||||
->count();
|
||||
$this->assertEquals(1, $count);
|
||||
}
|
||||
|
||||
private function simulateDelDeputy(WebSocketDialog $dialog, int $userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return ['ret' => 0, 'msg' => '请选择有效的成员'];
|
||||
}
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return ['ret' => 1, 'msg' => '罢免成功']; // 幂等
|
||||
}
|
||||
if ((int)$member->role === 2) {
|
||||
$member->role = 0;
|
||||
$member->save();
|
||||
}
|
||||
return ['ret' => 1, 'msg' => '罢免成功'];
|
||||
}
|
||||
|
||||
public function test_deldeputy_demotes_role_2_to_0()
|
||||
{
|
||||
$owner = $this->makeUser('o9@test.local');
|
||||
$deputy = $this->makeUser('d9@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$deputy->userid]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)
|
||||
->update(['role' => 2]);
|
||||
|
||||
$result = $this->simulateDelDeputy($dialog, $deputy->userid);
|
||||
$this->assertEquals(1, $result['ret']);
|
||||
|
||||
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)->value('role');
|
||||
$this->assertEquals(0, (int)$role, '罢免后应降为普通成员');
|
||||
$this->assertNotContains($deputy->userid, $dialog->fresh()->deputy_ids);
|
||||
}
|
||||
|
||||
public function test_deldeputy_idempotent_for_non_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('o9b@test.local');
|
||||
$member = $this->makeUser('m9b@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
|
||||
|
||||
// 普通成员(role=0)调用罢免应幂等返回成功
|
||||
$result = $this->simulateDelDeputy($dialog, $member->userid);
|
||||
$this->assertEquals(1, $result['ret']);
|
||||
|
||||
// 角色仍是 0
|
||||
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $member->userid)->value('role');
|
||||
$this->assertEquals(0, (int)$role);
|
||||
}
|
||||
|
||||
public function test_deldeputy_does_not_affect_primary_owner()
|
||||
{
|
||||
$owner = $this->makeUser('o9c@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid);
|
||||
|
||||
// 试图对主群主调用罢免(不应改变其 role=1)
|
||||
$result = $this->simulateDelDeputy($dialog, $owner->userid);
|
||||
$this->assertEquals(1, $result['ret']); // 幂等返回成功
|
||||
|
||||
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $owner->userid)->value('role');
|
||||
$this->assertEquals(1, (int)$role, '主群主 role 不应被罢免接口改变');
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 exitGroup 的权限检查逻辑(与 WebSocketDialog::exitGroup 中的 checkDelete 块保持一致)。
|
||||
* 用于在无 Swoole RequestContext 的 PHPUnit 环境中直接验证权限规则,而无需模拟 User::userid()。
|
||||
*
|
||||
* @param WebSocketDialog $dialog
|
||||
* @param int $actorId 执行操作的用户 ID
|
||||
* @param int $targetId 被移出的用户 ID
|
||||
* @return array ['allowed' => bool, 'error' => string|null]
|
||||
*/
|
||||
private function simulateRemovePermission(WebSocketDialog $dialog, int $actorId, int $targetId): array
|
||||
{
|
||||
if ($dialog->group_type === 'all') {
|
||||
return ['allowed' => false, 'error' => '仅管理员可操作全员群'];
|
||||
}
|
||||
|
||||
// 未认证时拒绝
|
||||
if ($actorId <= 0) {
|
||||
return ['allowed' => false, 'error' => '只有群主或邀请人可以移出成员'];
|
||||
}
|
||||
|
||||
// 获取目标成员记录
|
||||
$item = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $targetId)->first();
|
||||
if (!$item) {
|
||||
return ['allowed' => false, 'error' => '目标用户不在群内'];
|
||||
}
|
||||
|
||||
// 主群主、副群主、邀请人可移出
|
||||
$allowedActor = $dialog->isOwner($actorId) || $actorId === (int)$item->inviter;
|
||||
if (!$allowedActor) {
|
||||
return ['allowed' => false, 'error' => '只有群主或邀请人可以移出成员'];
|
||||
}
|
||||
|
||||
// 副群主不能移出主群主或其他副群主
|
||||
if ($dialog->isDeputyOwner($actorId)) {
|
||||
$targetIsOwner = $dialog->isPrimaryOwner($targetId) || $dialog->isDeputyOwner($targetId);
|
||||
if ($targetIsOwner) {
|
||||
return ['allowed' => false, 'error' => '群管理员不能移出群主或其他群管理员'];
|
||||
}
|
||||
}
|
||||
|
||||
// 群主不可被移出(额外保障,与 exitGroup 行为一致)
|
||||
if ($targetId == $dialog->owner_id) {
|
||||
return ['allowed' => false, 'error' => '群主不可移出'];
|
||||
}
|
||||
|
||||
return ['allowed' => true, 'error' => null];
|
||||
}
|
||||
|
||||
public function test_deputy_can_remove_normal_member()
|
||||
{
|
||||
$owner = $this->makeUser('o10@test.local');
|
||||
$deputy = $this->makeUser('d10@test.local');
|
||||
$member = $this->makeUser('m10@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$deputy->userid, $member->userid]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)->update(['role' => 2]);
|
||||
|
||||
// 验证权限逻辑:副群主可移出普通成员
|
||||
$result = $this->simulateRemovePermission($dialog, $deputy->userid, $member->userid);
|
||||
$this->assertTrue($result['allowed'], '副群主应能移出普通成员,错误:' . ($result['error'] ?? ''));
|
||||
|
||||
// 验证实际移出操作(checkDelete=false 绕过 auth,直接测试 DB 状态)
|
||||
$dialog->exitGroup($member->userid, 'remove', false, false);
|
||||
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $member->userid)->exists();
|
||||
$this->assertFalse($exists, '移出后成员记录应不存在');
|
||||
}
|
||||
|
||||
public function test_deputy_cannot_remove_primary_owner()
|
||||
{
|
||||
$owner = $this->makeUser('o10b@test.local');
|
||||
$deputy = $this->makeUser('d10b@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$deputy->userid]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)->update(['role' => 2]);
|
||||
|
||||
// 验证权限逻辑:副群主不可移出主群主
|
||||
$result = $this->simulateRemovePermission($dialog, $deputy->userid, $owner->userid);
|
||||
$this->assertFalse($result['allowed'], '副群主不应能移出主群主');
|
||||
$this->assertNotNull($result['error']);
|
||||
}
|
||||
|
||||
public function test_deputy_cannot_remove_other_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('o10c@test.local');
|
||||
$deputy1 = $this->makeUser('d10c1@test.local');
|
||||
$deputy2 = $this->makeUser('d10c2@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$deputy1->userid, $deputy2->userid]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->whereIn('userid', [$deputy1->userid, $deputy2->userid])
|
||||
->update(['role' => 2]);
|
||||
|
||||
// 验证权限逻辑:副群主不可移出其他副群主
|
||||
$result = $this->simulateRemovePermission($dialog, $deputy1->userid, $deputy2->userid);
|
||||
$this->assertFalse($result['allowed'], '副群主不应能移出其他副群主');
|
||||
$this->assertEquals('群管理员不能移出群主或其他群管理员', $result['error']);
|
||||
}
|
||||
|
||||
public function test_inviter_can_still_remove_invitee()
|
||||
{
|
||||
$owner = $this->makeUser('o10d@test.local');
|
||||
$inviter = $this->makeUser('inv10d@test.local');
|
||||
$invitee = $this->makeUser('iv10d@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$inviter->userid, $invitee->userid]);
|
||||
|
||||
// 设置 invitee 的 inviter 字段
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $invitee->userid)
|
||||
->update(['inviter' => $inviter->userid]);
|
||||
|
||||
// 验证权限逻辑:邀请人可移出被邀请者
|
||||
$result = $this->simulateRemovePermission($dialog, $inviter->userid, $invitee->userid);
|
||||
$this->assertTrue($result['allowed'], '邀请人应能移出被邀请者,错误:' . ($result['error'] ?? ''));
|
||||
|
||||
// 验证实际移出
|
||||
$dialog->exitGroup($invitee->userid, 'remove', false, false);
|
||||
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $invitee->userid)->exists();
|
||||
$this->assertFalse($exists, '邀请人移出后被邀请者记录应不存在');
|
||||
}
|
||||
|
||||
public function test_non_owner_non_inviter_cannot_remove_member()
|
||||
{
|
||||
$owner = $this->makeUser('o10e@test.local');
|
||||
$member1 = $this->makeUser('m10e1@test.local');
|
||||
$member2 = $this->makeUser('m10e2@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$member1->userid, $member2->userid]);
|
||||
|
||||
// 普通成员无法移出其他成员
|
||||
$result = $this->simulateRemovePermission($dialog, $member1->userid, $member2->userid);
|
||||
$this->assertFalse($result['allowed'], '普通成员不应能移出其他成员');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证离职移交时副群主角色被正确清除。
|
||||
*
|
||||
* UserTransfer::exitDialog() 对离职用户调用 exitGroup($original_userid, 'remove', false, false),
|
||||
* exitGroup 内部直接 hard-delete web_socket_dialog_users 记录($item->delete()),
|
||||
* 因此副群主的 role 随记录一起消失,无需额外逻辑。
|
||||
*
|
||||
* 本测试直接调用 exitDialog()(通过 UserTransfer 实例),绕过 start() 中的项目/任务/文件迁移,
|
||||
* 以确保在无 Swoole 推送的 PHPUnit 环境中可以正常运行。
|
||||
*/
|
||||
public function test_user_transfer_clears_deputy_role()
|
||||
{
|
||||
$owner = $this->makeUser('o11@test.local');
|
||||
$departing = $this->makeUser('dep11@test.local');
|
||||
$receiver = $this->makeUser('rec11@test.local');
|
||||
$dialog = $this->makeGroup($owner->userid, [$departing->userid, $receiver->userid]);
|
||||
|
||||
// 将离职用户设为副群主
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $departing->userid)
|
||||
->update(['role' => 2]);
|
||||
|
||||
// 验证前置条件
|
||||
$this->assertContains($departing->userid, $dialog->fresh()->deputy_ids, '前置条件:离职用户应是副群主');
|
||||
|
||||
// 通过 UserTransfer 触发 exitDialog(使用正确字段名 original_userid / new_userid)
|
||||
$transfer = \App\Models\UserTransfer::createInstance([
|
||||
'original_userid' => $departing->userid,
|
||||
'new_userid' => $receiver->userid,
|
||||
]);
|
||||
$transfer->save();
|
||||
$transfer->exitDialog();
|
||||
|
||||
$freshDialog = $dialog->fresh();
|
||||
|
||||
// 离职用户不应再出现在副群主列表中
|
||||
$this->assertNotContains($departing->userid, $freshDialog->deputy_ids, '离职用户不应留在副群主列表');
|
||||
// 离职用户的成员记录应已删除
|
||||
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $departing->userid)
|
||||
->exists();
|
||||
$this->assertFalse($exists, 'exitDialog 后离职用户的成员记录应被删除');
|
||||
// 接收方不应自动继承副群主角色
|
||||
$this->assertNotContains($receiver->userid, $freshDialog->deputy_ids, '接收方不应自动继承副群主角色');
|
||||
}
|
||||
}
|
||||
638
tests/Feature/MultiOwnerProjectTest.php
Normal file
638
tests/Feature/MultiOwnerProjectTest.php
Normal file
@ -0,0 +1,638 @@
|
||||
<?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 App\Models\WebSocketDialogUser;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MultiOwnerProjectTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* 创建用户。密码用 md5(pre_users.password 是 VARCHAR(50),bcrypt 会被截断)。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试项目,附带 N 个成员;自动创建关联项目群。
|
||||
*/
|
||||
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::transfer (after Task 5 changes).
|
||||
* 关键:必须 syncDialogUser 同步 role,不要手写 update。
|
||||
*/
|
||||
private function simulateTransfer(Project $project, int $newOwnerUserid): void
|
||||
{
|
||||
AbstractModel::transaction(function () use ($project, $newOwnerUserid) {
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(1)
|
||||
->change(['owner' => 0]);
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $newOwnerUserid,
|
||||
], ['owner' => 1]);
|
||||
if ($project->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($project->dialog_id);
|
||||
if ($dialog) {
|
||||
$dialog->owner_id = $newOwnerUserid;
|
||||
$dialog->save();
|
||||
}
|
||||
}
|
||||
$project->fresh()->syncDialogUser();
|
||||
});
|
||||
}
|
||||
|
||||
private function simulateAddDeputy(Project $project, int $userid): void
|
||||
{
|
||||
$member = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $userid)->first();
|
||||
if (!$member) throw new \RuntimeException('not member');
|
||||
if ((int)$member->owner === 1) throw new \RuntimeException('cannot deputy primary');
|
||||
if ((int)$member->owner !== 2) {
|
||||
AbstractModel::transaction(function () use ($project, $member) {
|
||||
$member->owner = 2;
|
||||
$member->save();
|
||||
$project->fresh()->syncDialogUser();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function simulateDelDeputy(Project $project, int $userid): void
|
||||
{
|
||||
$member = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $userid)->first();
|
||||
if (!$member) return;
|
||||
if ((int)$member->owner === 2) {
|
||||
AbstractModel::transaction(function () use ($project, $member) {
|
||||
$member->owner = 0;
|
||||
$member->save();
|
||||
$project->fresh()->syncDialogUser();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟合并后的 ProjectController::user() 端点:同步成员 + 副负责人。
|
||||
*
|
||||
* @param Project $project 项目实例
|
||||
* @param int $callerUserid 调用方 userid(用于权限判断)
|
||||
* @param int[] $userids 最终成员完整列表(必须包含主负责人)
|
||||
* @param int[]|null $deputyUserids 最终副负责人完整列表;null 表示不设置(沿用既有副)
|
||||
* @return int[] 被移除的成员 userids
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
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_setup_works()
|
||||
{
|
||||
$owner = $this->makeUser('p1_owner@test.local');
|
||||
$member = $this->makeUser('p1_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member->userid]);
|
||||
|
||||
$this->assertEquals($owner->userid, $project->owner_userid);
|
||||
$this->assertNotEmpty($project->dialog_id);
|
||||
}
|
||||
|
||||
public function test_projectuser_constants_and_helpers()
|
||||
{
|
||||
$owner = $this->makeUser('p2_o@test.local');
|
||||
$deputy = $this->makeUser('p2_d@test.local');
|
||||
$member = $this->makeUser('p2_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]);
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
|
||||
$ownerRow = ProjectUser::where('project_id', $project->id)->where('userid', $owner->userid)->first();
|
||||
$deputyRow = ProjectUser::where('project_id', $project->id)->where('userid', $deputy->userid)->first();
|
||||
$memberRow = ProjectUser::where('project_id', $project->id)->where('userid', $member->userid)->first();
|
||||
|
||||
$this->assertTrue($ownerRow->isPrimaryOwner());
|
||||
$this->assertFalse($ownerRow->isDeputyOwner());
|
||||
$this->assertTrue($ownerRow->isOwner());
|
||||
|
||||
$this->assertFalse($deputyRow->isPrimaryOwner());
|
||||
$this->assertTrue($deputyRow->isDeputyOwner());
|
||||
$this->assertTrue($deputyRow->isOwner());
|
||||
|
||||
$this->assertFalse($memberRow->isPrimaryOwner());
|
||||
$this->assertFalse($memberRow->isDeputyOwner());
|
||||
$this->assertFalse($memberRow->isOwner());
|
||||
}
|
||||
|
||||
public function test_project_helpers_and_deputy_userids()
|
||||
{
|
||||
$owner = $this->makeUser('p3_o@test.local');
|
||||
$d1 = $this->makeUser('p3_d1@test.local');
|
||||
$d2 = $this->makeUser('p3_d2@test.local');
|
||||
$member = $this->makeUser('p3_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$d1->userid, $d2->userid, $member->userid]);
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->whereIn('userid', [$d1->userid, $d2->userid])
|
||||
->update(['owner' => 2]);
|
||||
|
||||
$project = $project->fresh();
|
||||
|
||||
$this->assertTrue($project->isPrimaryOwner($owner->userid));
|
||||
$this->assertFalse($project->isPrimaryOwner($d1->userid));
|
||||
$this->assertFalse($project->isPrimaryOwner($member->userid));
|
||||
|
||||
$this->assertFalse($project->isDeputyOwner($owner->userid));
|
||||
$this->assertTrue($project->isDeputyOwner($d1->userid));
|
||||
$this->assertTrue($project->isDeputyOwner($d2->userid));
|
||||
$this->assertFalse($project->isDeputyOwner($member->userid));
|
||||
|
||||
$this->assertTrue($project->isOwner($owner->userid));
|
||||
$this->assertTrue($project->isOwner($d1->userid));
|
||||
$this->assertFalse($project->isOwner($member->userid));
|
||||
|
||||
// deputy_userids 排序无关,比较成 set
|
||||
$deputyIds = $project->deputy_userids;
|
||||
sort($deputyIds);
|
||||
$expected = [$d1->userid, $d2->userid];
|
||||
sort($expected);
|
||||
$this->assertEquals($expected, $deputyIds);
|
||||
|
||||
// 序列化后 API 响应里应包含 deputy_userids
|
||||
$arr = $project->toArray();
|
||||
$this->assertArrayHasKey('deputy_userids', $arr);
|
||||
}
|
||||
|
||||
public function test_syncDialogUser_syncs_role_from_owner()
|
||||
{
|
||||
$owner = $this->makeUser('p4_o@test.local');
|
||||
$deputy = $this->makeUser('p4_d@test.local');
|
||||
$member = $this->makeUser('p4_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]);
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
|
||||
$project->fresh()->syncDialogUser();
|
||||
|
||||
$dialog = WebSocketDialog::find($project->dialog_id);
|
||||
$this->assertEquals(1, (int)WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $owner->userid)->value('role'));
|
||||
$this->assertEquals(2, (int)WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $deputy->userid)->value('role'));
|
||||
$this->assertEquals(0, (int)WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $member->userid)->value('role'));
|
||||
}
|
||||
|
||||
public function test_syncDialogUser_clears_role_for_demoted_user()
|
||||
{
|
||||
$owner = $this->makeUser('p4b_o@test.local');
|
||||
$deputy = $this->makeUser('p4b_d@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid]);
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
$project->fresh()->syncDialogUser();
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 0]);
|
||||
$project->fresh()->syncDialogUser();
|
||||
|
||||
$this->assertEquals(0, (int)WebSocketDialogUser::where('dialog_id', $project->dialog_id)
|
||||
->where('userid', $deputy->userid)->value('role'));
|
||||
}
|
||||
|
||||
public function test_transfer_updates_dialog_owner_and_role()
|
||||
{
|
||||
$oldOwner = $this->makeUser('p5_old@test.local');
|
||||
$newOwner = $this->makeUser('p5_new@test.local');
|
||||
$project = $this->makeProject($oldOwner->userid, [$newOwner->userid]);
|
||||
|
||||
$this->simulateTransfer($project, $newOwner->userid);
|
||||
|
||||
$project = $project->fresh();
|
||||
$this->assertEquals($newOwner->userid, $project->owner_userid);
|
||||
$dialog = WebSocketDialog::find($project->dialog_id);
|
||||
$this->assertEquals($newOwner->userid, (int)$dialog->owner_id);
|
||||
$this->assertEquals(1, (int)WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $newOwner->userid)->value('role'));
|
||||
$this->assertEquals(0, (int)WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwner->userid)->value('role'));
|
||||
}
|
||||
|
||||
public function test_transfer_preserves_deputies()
|
||||
{
|
||||
$oldOwner = $this->makeUser('p5b_old@test.local');
|
||||
$newOwner = $this->makeUser('p5b_new@test.local');
|
||||
$deputy = $this->makeUser('p5b_d@test.local');
|
||||
$project = $this->makeProject($oldOwner->userid, [$newOwner->userid, $deputy->userid]);
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
$project->fresh()->syncDialogUser(); // sync deputy role=2 first
|
||||
|
||||
$this->simulateTransfer($project, $newOwner->userid);
|
||||
|
||||
$project = $project->fresh();
|
||||
$this->assertContains($deputy->userid, $project->deputy_userids);
|
||||
$this->assertEquals(2, (int)WebSocketDialogUser::where('dialog_id', $project->dialog_id)
|
||||
->where('userid', $deputy->userid)->value('role'));
|
||||
}
|
||||
|
||||
public function test_transfer_demotes_deputy_when_promoted_to_primary()
|
||||
{
|
||||
$oldOwner = $this->makeUser('p5c_old@test.local');
|
||||
$deputy = $this->makeUser('p5c_d@test.local');
|
||||
$project = $this->makeProject($oldOwner->userid, [$deputy->userid]);
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
$project->fresh()->syncDialogUser();
|
||||
|
||||
$this->simulateTransfer($project, $deputy->userid);
|
||||
|
||||
$project = $project->fresh();
|
||||
$this->assertEquals($deputy->userid, $project->owner_userid);
|
||||
$this->assertNotContains($deputy->userid, $project->deputy_userids);
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $deputy->userid)->first();
|
||||
$this->assertEquals(1, (int)$row->owner);
|
||||
}
|
||||
|
||||
public function test_adddeputy_marks_owner_2_and_syncs_dialog_role()
|
||||
{
|
||||
$owner = $this->makeUser('p6_o@test.local');
|
||||
$member = $this->makeUser('p6_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member->userid]);
|
||||
|
||||
$this->simulateAddDeputy($project, $member->userid);
|
||||
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $member->userid)->first();
|
||||
$this->assertEquals(2, (int)$row->owner);
|
||||
$this->assertEquals(2, (int)WebSocketDialogUser::where('dialog_id', $project->dialog_id)
|
||||
->where('userid', $member->userid)->value('role'));
|
||||
$this->assertContains($member->userid, $project->fresh()->deputy_userids);
|
||||
}
|
||||
|
||||
public function test_adddeputy_idempotent()
|
||||
{
|
||||
$owner = $this->makeUser('p6b_o@test.local');
|
||||
$member = $this->makeUser('p6b_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member->userid]);
|
||||
|
||||
$this->simulateAddDeputy($project, $member->userid);
|
||||
$this->simulateAddDeputy($project, $member->userid); // 第二次不应报错
|
||||
|
||||
$count = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $member->userid)->count();
|
||||
$this->assertEquals(1, $count);
|
||||
}
|
||||
|
||||
public function test_adddeputy_rejects_primary_owner()
|
||||
{
|
||||
$owner = $this->makeUser('p6c_o@test.local');
|
||||
$project = $this->makeProject($owner->userid);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->simulateAddDeputy($project, $owner->userid);
|
||||
}
|
||||
|
||||
public function test_adddeputy_rejects_non_member()
|
||||
{
|
||||
$owner = $this->makeUser('p6d_o@test.local');
|
||||
$outsider = $this->makeUser('p6d_x@test.local');
|
||||
$project = $this->makeProject($owner->userid);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->simulateAddDeputy($project, $outsider->userid);
|
||||
}
|
||||
|
||||
public function test_deldeputy_demotes_to_member_and_clears_dialog_role()
|
||||
{
|
||||
$owner = $this->makeUser('p7_o@test.local');
|
||||
$deputy = $this->makeUser('p7_d@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid]);
|
||||
$this->simulateAddDeputy($project, $deputy->userid);
|
||||
|
||||
$this->simulateDelDeputy($project, $deputy->userid);
|
||||
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $deputy->userid)->first();
|
||||
$this->assertEquals(0, (int)$row->owner);
|
||||
$this->assertEquals(0, (int)WebSocketDialogUser::where('dialog_id', $project->dialog_id)
|
||||
->where('userid', $deputy->userid)->value('role'));
|
||||
}
|
||||
|
||||
public function test_deldeputy_idempotent_for_non_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('p7b_o@test.local');
|
||||
$member = $this->makeUser('p7b_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member->userid]);
|
||||
|
||||
$this->simulateDelDeputy($project, $member->userid);
|
||||
$this->assertTrue(true); // 没抛 = 通过
|
||||
}
|
||||
|
||||
public function test_owner_userids_query_includes_primary_and_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('p8_o@test.local');
|
||||
$deputy = $this->makeUser('p8_d@test.local');
|
||||
$member = $this->makeUser('p8_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]);
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
|
||||
$ownerUserids = ProjectUser::whereProjectId($project->id)
|
||||
->whereIn('owner', [1, 2])
|
||||
->pluck('userid')->map(fn($v) => (int)$v)->toArray();
|
||||
sort($ownerUserids);
|
||||
$expected = [$owner->userid, $deputy->userid];
|
||||
sort($expected);
|
||||
$this->assertEquals($expected, $ownerUserids);
|
||||
}
|
||||
|
||||
public function test_userProject_primary_mode_distinguishes_primary_from_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('p8c_o@test.local');
|
||||
$deputy = $this->makeUser('p8c_d@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid]);
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->update(['owner' => 2]);
|
||||
|
||||
$row = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)->first();
|
||||
$this->assertEquals(2, (int)$row->owner);
|
||||
$this->assertTrue((int)$row->owner !== 1, 'deputy must not be primary');
|
||||
$this->assertTrue((bool)$row->owner, 'deputy is truthy (passes mustOwner=true)');
|
||||
}
|
||||
|
||||
public function test_user_transfer_clears_departing_deputy_role()
|
||||
{
|
||||
$owner = $this->makeUser('p9_o@test.local');
|
||||
$departing = $this->makeUser('p9_dep@test.local');
|
||||
$receiver = $this->makeUser('p9_rec@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$departing->userid, $receiver->userid]);
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $departing->userid)->update(['owner' => 2]);
|
||||
|
||||
\App\Models\ProjectUser::transfer($departing->userid, $receiver->userid);
|
||||
|
||||
// 离职的副已不在 project_users
|
||||
$this->assertFalse(
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $departing->userid)->exists()
|
||||
);
|
||||
// receiver 没有继承副身份
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $receiver->userid)->first();
|
||||
$this->assertNotEquals(2, (int)$row->owner);
|
||||
$this->assertNotContains($receiver->userid, $project->fresh()->deputy_userids);
|
||||
}
|
||||
|
||||
public function test_user_transfer_inherits_departing_primary_role()
|
||||
{
|
||||
$departing = $this->makeUser('p9b_dep@test.local');
|
||||
$receiver = $this->makeUser('p9b_rec@test.local');
|
||||
$project = $this->makeProject($departing->userid, [$receiver->userid]);
|
||||
|
||||
\App\Models\ProjectUser::transfer($departing->userid, $receiver->userid);
|
||||
|
||||
$this->assertEquals($receiver->userid, $project->fresh()->owner_userid);
|
||||
}
|
||||
|
||||
public function test_user_transfer_promotes_existing_member_when_inherits_primary()
|
||||
{
|
||||
// 离职=主, receiver=普通成员 → receiver 应升为主
|
||||
$departing = $this->makeUser('p9c_dep@test.local');
|
||||
$receiver = $this->makeUser('p9c_rec@test.local');
|
||||
$project = $this->makeProject($departing->userid, [$receiver->userid]);
|
||||
|
||||
\App\Models\ProjectUser::transfer($departing->userid, $receiver->userid);
|
||||
|
||||
$project = $project->fresh();
|
||||
$this->assertEquals($receiver->userid, $project->owner_userid);
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $receiver->userid)->first();
|
||||
$this->assertEquals(1, (int)$row->owner);
|
||||
}
|
||||
|
||||
public function test_user_transfer_keeps_receiver_primary_when_departing_is_member()
|
||||
{
|
||||
// receiver=主, departing=普通成员 → receiver 保持主
|
||||
$receiver = $this->makeUser('p9d_rec@test.local');
|
||||
$departing = $this->makeUser('p9d_dep@test.local');
|
||||
$project = $this->makeProject($receiver->userid, [$departing->userid]);
|
||||
|
||||
\App\Models\ProjectUser::transfer($departing->userid, $receiver->userid);
|
||||
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $receiver->userid)->first();
|
||||
$this->assertEquals(1, (int)$row->owner);
|
||||
}
|
||||
|
||||
public function test_member_sync_promotes_existing_member_to_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('m1_o@test.local');
|
||||
$member = $this->makeUser('m1_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member->userid]);
|
||||
|
||||
$this->simulateMemberSync(
|
||||
$project,
|
||||
$owner->userid,
|
||||
[$owner->userid, $member->userid],
|
||||
[$member->userid]
|
||||
);
|
||||
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $member->userid)->first();
|
||||
$this->assertEquals(ProjectUser::OWNER_DEPUTY, (int)$row->owner);
|
||||
$this->assertContains($member->userid, $project->fresh()->deputy_userids);
|
||||
$this->assertEquals(2, (int)WebSocketDialogUser::where('dialog_id', $project->dialog_id)
|
||||
->where('userid', $member->userid)->value('role'));
|
||||
}
|
||||
|
||||
public function test_member_sync_demotes_deputy_kept_as_member()
|
||||
{
|
||||
$owner = $this->makeUser('m2_o@test.local');
|
||||
$deputy = $this->makeUser('m2_d@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid]);
|
||||
$this->simulateAddDeputy($project, $deputy->userid);
|
||||
|
||||
$this->simulateMemberSync(
|
||||
$project,
|
||||
$owner->userid,
|
||||
[$owner->userid, $deputy->userid],
|
||||
[]
|
||||
);
|
||||
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $deputy->userid)->first();
|
||||
$this->assertEquals(ProjectUser::OWNER_MEMBER, (int)$row->owner);
|
||||
$this->assertNotContains($deputy->userid, $project->fresh()->deputy_userids);
|
||||
}
|
||||
|
||||
public function test_member_sync_removes_deputy_from_project_in_one_call()
|
||||
{
|
||||
$owner = $this->makeUser('m3_o@test.local');
|
||||
$deputy = $this->makeUser('m3_d@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid]);
|
||||
$this->simulateAddDeputy($project, $deputy->userid);
|
||||
|
||||
$deleted = $this->simulateMemberSync(
|
||||
$project,
|
||||
$owner->userid,
|
||||
[$owner->userid],
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertContains($deputy->userid, $deleted);
|
||||
$row = ProjectUser::where('project_id', $project->id)->where('userid', $deputy->userid)->first();
|
||||
$this->assertNull($row);
|
||||
}
|
||||
|
||||
public function test_member_sync_rejects_deputy_not_in_member_list()
|
||||
{
|
||||
$owner = $this->makeUser('m4_o@test.local');
|
||||
$outsider = $this->makeUser('m4_x@test.local');
|
||||
$project = $this->makeProject($owner->userid);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('deputy must be member');
|
||||
|
||||
$this->simulateMemberSync(
|
||||
$project,
|
||||
$owner->userid,
|
||||
[$owner->userid],
|
||||
[$outsider->userid]
|
||||
);
|
||||
}
|
||||
|
||||
public function test_member_sync_rejects_primary_in_deputy_list()
|
||||
{
|
||||
$owner = $this->makeUser('m5_o@test.local');
|
||||
$project = $this->makeProject($owner->userid);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('primary cannot be deputy');
|
||||
|
||||
$this->simulateMemberSync(
|
||||
$project,
|
||||
$owner->userid,
|
||||
[$owner->userid],
|
||||
[$owner->userid]
|
||||
);
|
||||
}
|
||||
|
||||
public function test_member_sync_ignores_deputy_field_when_caller_is_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('m6_o@test.local');
|
||||
$deputy = $this->makeUser('m6_d@test.local');
|
||||
$member = $this->makeUser('m6_m@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]);
|
||||
$this->simulateAddDeputy($project, $deputy->userid);
|
||||
|
||||
$this->simulateMemberSync(
|
||||
$project,
|
||||
$deputy->userid,
|
||||
[$owner->userid, $deputy->userid, $member->userid],
|
||||
[$member->userid]
|
||||
);
|
||||
|
||||
$memberRow = ProjectUser::where('project_id', $project->id)->where('userid', $member->userid)->first();
|
||||
$this->assertEquals(ProjectUser::OWNER_MEMBER, (int)$memberRow->owner);
|
||||
$deputyRow = ProjectUser::where('project_id', $project->id)->where('userid', $deputy->userid)->first();
|
||||
$this->assertEquals(ProjectUser::OWNER_DEPUTY, (int)$deputyRow->owner);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user