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:
kuaifan 2026-05-03 00:05:31 +00:00
parent 2a3f05e06f
commit 24710289e1
26 changed files with 2895 additions and 121 deletions

3
.gitignore vendored
View File

@ -61,3 +61,6 @@ laravels.pid
# Documentation
README_LOCAL.md
# playwright
.playwright-mcp/

View File

@ -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 解散群组
*

View File

@ -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();
}
}
// 同步成员 + rolesyncDialogUser 已根据 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);
}

View File

@ -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 删除部门(限管理员)
*

View File

@ -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 ]);
}

View File

@ -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)) {

View File

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

View File

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

View File

@ -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,

View File

@ -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)) {

View File

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

View File

@ -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");
}
}

View File

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

View File

@ -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
}
}

View File

@ -974,3 +974,21 @@ AI 返回内容为空
没有权限操作此任务
请选择要转发的消息
LDAP 用户缺少邮箱属性,请联系管理员配置
群管理员
任命群管理员
罢免群管理员
该用户不是群成员
不能将群主任命为群管理员
仅群主或群管理员可操作
仅限群主或群管理员操作
群管理员不能移出群主或其他群管理员
请选择有效的成员
任命成功
罢免成功
项目管理员
任命项目管理员
罢免项目管理员
该用户不是项目成员
不能将负责人任命为项目管理员
不能将部门负责人任命为部门管理员
该用户不存在

View File

@ -2361,3 +2361,26 @@ AI任务分析
登录属性
用于匹配登录用户名的 LDAP 属性Active Directory 请选择 sAMAccountName
请输入帐号
群管理员
任命群管理员
罢免群管理员
确定要罢免该群管理员吗?
还没有群管理员
添加群管理员
确定将 (*) 任命为群管理员吗?
项目管理员
任命项目管理员
罢免项目管理员
确定要罢免该项目管理员吗?
还没有项目管理员
添加项目管理员
确定将 (*) 任命为项目管理员吗?
部门管理员
任命部门管理员
罢免部门管理员
请选择部门管理员
确定将 (*) 任命为部门管理员吗?
部门管理员享有部门群的群管理员权限
即将罢免项目管理员
请确认以下操作,注意此操作不可逆!
移除成员负责的任务将变成无负责人。

View File

@ -19,6 +19,7 @@
</div>
<div v-if="showName" class="avatar-name" :style="nameStyle">
<div v-if="user.bot" class="taskfont bot">&#xe68c;</div>
<slot name="name-prefix"/>
<span>{{nameText || user.nickname}}</span>
</div>
</div>

View File

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

View File

@ -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) {

View File

@ -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) {

View File

@ -323,7 +323,8 @@ body.dark-mode-reverse {
.group-info-user {
> ul {
> li {
.user-tag {
.user-tag,
.deputy-tag {
color: #1c1917;
}
}

View File

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

View File

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

View 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 tableget 始终返回 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=2pushMsg=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());
}
}

View 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, '接收方不应自动继承副群主角色');
}
}

View 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;
/**
* 创建用户。密码用 md5pre_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);
}
}