feat(multi-owner): 群/项目/部门支持主+副双负责人体系

This commit is contained in:
kuaifan 2026-05-09 12:29:38 +00:00
parent 24710289e1
commit 64649b514e
15 changed files with 118 additions and 118 deletions

View File

@ -2897,7 +2897,7 @@ class DialogController extends AbstractController
}
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
// 有群主(主或副)时,仅群主/副群主可邀请;无群主时,任意成员可邀请
// 有群主时,仅群主/群管理员可邀请;无群主时,任意成员可邀请
if ($dialog->owner_id > 0 && !$dialog->isOwner($user->userid)) {
throw new \App\Exceptions\ApiException('仅限群主或群管理员操作');
}
@ -3013,7 +3013,7 @@ class DialogController extends AbstractController
}
/**
* 任命副群主(仅主群主可操作)
* 任命群管理员(仅群主可操作)
*
* @apiParam {Number} dialog_id 群对话ID
* @apiParam {Number} userid 要任命的群成员 userid
@ -3028,7 +3028,7 @@ class DialogController extends AbstractController
return Base::retError('请选择有效的成员');
}
$dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true群主
$dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true群主
$dialog->checkGroup('user'); // 仅普通群
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
@ -3054,10 +3054,10 @@ class DialogController extends AbstractController
}
/**
* 罢免副群主(仅主群主可操作)
* 罢免群管理员(仅群主可操作)
*
* @apiParam {Number} dialog_id 群对话ID
* @apiParam {Number} userid 要罢免的副群主 userid
* @apiParam {Number} userid 要罢免的群管理员 userid
*/
public function group__deldeputy()
{

View File

@ -381,7 +381,7 @@ class ProjectController extends AbstractController
*
* @apiParam {Number} project_id 项目ID
* @apiParam {Number[]} userid 成员userid数组最终完整列表
* @apiParam {Number[]} [deputy_userid] 副负责人userid数组可选仅主负责人有效;必须是 userid 子集)
* @apiParam {Number[]} [deputy_userid] 项目管理员userid数组可选负责人有效;必须是 userid 子集)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -408,7 +408,7 @@ class ProjectController extends AbstractController
//
$project = Project::userProject($project_id, true, true);
//
// 仅主负责人可设置副负责人;副负责人/其他角色提交 deputy_userid 一律忽略
// 仅负责人可设置项目管理员;项目管理员/其他角色提交 deputy_userid 一律忽略
$isPrimary = (int)$project->owner === ProjectUser::OWNER_PRIMARY;
$applyDeputy = $isPrimary && $deputy_userid !== null;
//
@ -434,7 +434,7 @@ class ProjectController extends AbstractController
$row->exitProject();
}
//
// 副负责人 diff仅主负责人有效)
// 项目管理员 diff负责人有效)
if ($applyDeputy) {
$currentDeputies = ProjectUser::whereProjectId($project->id)
->where('owner', ProjectUser::OWNER_DEPUTY)
@ -624,11 +624,11 @@ class ProjectController extends AbstractController
}
//
AbstractModel::transaction(function() use ($owner_userid, $project) {
// 仅清除原主 owner=1 owner=2 保留)
// 仅清除原负责人 owner=1项目管理员 owner=2 保留)
ProjectUser::whereProjectId($project->id)
->whereOwner(ProjectUser::OWNER_PRIMARY)
->change(['owner' => 0]);
// 设新主 owner=1如新主原本是副,从 2 升为 1
// 设新负责人 owner=1如新负责人原本是项目管理员,从 2 升为 1
ProjectUser::updateInsert([
'project_id' => $project->id,
'userid' => $owner_userid,
@ -648,7 +648,7 @@ class ProjectController extends AbstractController
$project->addLog("移交项目给", ['userid' => $owner_userid]);
});
//
// pushMsg 带 deputy_userids前端可直接更新列表无需重拉
// pushMsg 带 deputy_userids前端可直接更新项目管理员列表无需重拉
$project->pushMsg('detail', [
'owner_userid' => $project->fresh()->owner_userid,
'deputy_userids' => $project->fresh()->deputy_userids,
@ -657,7 +657,7 @@ class ProjectController extends AbstractController
}
/**
* @api {post} api/project/adddeputy 任命副负责人(仅主负责人可操作)
* @api {post} api/project/adddeputy 任命项目管理员(仅负责人可操作)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@ -707,7 +707,7 @@ class ProjectController extends AbstractController
}
/**
* @api {post} api/project/deldeputy 罢免副负责人(仅主负责人可操作)
* @api {post} api/project/deldeputy 罢免项目管理员(仅负责人可操作)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@ -715,7 +715,7 @@ class ProjectController extends AbstractController
* @apiName deldeputy
*
* @apiParam {Number} project_id 项目ID
* @apiParam {Number} userid 要罢免的副负责人 userid
* @apiParam {Number} userid 要罢免的项目管理员 userid
*/
public function deldeputy()
{
@ -1994,7 +1994,7 @@ class ProjectController extends AbstractController
// 项目可见性
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->map(fn($v) => (int)$v)->toArray(); // 项目负责人(主+副
->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(); //子任务负责人、协助人
@ -2504,7 +2504,7 @@ class ProjectController extends AbstractController
} else {
$projectOwner = ProjectUser::whereProjectId($task->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->toArray(); // 项目负责人(主+副
->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;

View File

@ -2146,7 +2146,7 @@ class UsersController extends AbstractController
}
/**
* @api {post} api/users/department/adddeputy 任命副负责人(限管理员)
* @api {post} api/users/department/adddeputy 任命部门管理员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@ -2154,7 +2154,7 @@ class UsersController extends AbstractController
* @apiName department__adddeputy
*
* @apiParam {Number} id 部门 id
* @apiParam {Number} userid 副负责人 userid
* @apiParam {Number} userid 部门管理员 userid
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -2178,7 +2178,7 @@ class UsersController extends AbstractController
}
/**
* @api {post} api/users/department/deldeputy 罢免副负责人(限管理员)
* @api {post} api/users/department/deldeputy 罢免部门管理员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@ -2186,7 +2186,7 @@ class UsersController extends AbstractController
* @apiName department__deldeputy
*
* @apiParam {Number} id 部门 id
* @apiParam {Number} userid 要罢免的副负责人 userid
* @apiParam {Number} userid 要罢免的部门管理员 userid
*/
public function department__deldeputy()
{

View File

@ -94,7 +94,7 @@ class Project extends AbstractModel
}
/**
* 副负责人 userid 列表
* 项目管理员 userid 列表
* @return array
*/
public function getDeputyUseridsAttribute(): array
@ -110,7 +110,7 @@ class Project extends AbstractModel
}
/**
* 是否负责人(与 project_users.owner=1 一致)
* 是否项目负责人(与 project_users.owner=1 一致)
*/
public function isPrimaryOwner($userid): bool
{
@ -124,7 +124,7 @@ class Project extends AbstractModel
}
/**
* 是否副负责人(与 project_users.owner=2 一致)
* 是否项目管理员(与 project_users.owner=2 一致)
*/
public function isDeputyOwner($userid): bool
{
@ -138,7 +138,7 @@ class Project extends AbstractModel
}
/**
* 是否负责人(主或副
* 是否负责人(含项目管理员
*/
public function isOwner($userid): bool
{
@ -693,8 +693,8 @@ class Project extends AbstractModel
* 获取项目信息(用于判断会员是否存在项目内)
* @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool|string $mustOwner true:主或副都可(主+共享操作);
* 'primary':主(转让/删除/任命副等主独占操作);
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
* 'primary':负责人(转让/删除/任命项目管理员等独占操作);
* false:仅限非负责人null:不限制
* @return self
*/

View File

@ -1993,7 +1993,7 @@ class ProjectTask extends AbstractModel
//
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->toArray(); // 项目负责人(主+副
->pluck('userid')->toArray(); // 项目负责人(含项目管理员
//
$array = [];
if (empty($userids)) {

View File

@ -39,13 +39,13 @@ class ProjectUser extends AbstractModel
{
/** @var int 普通成员编码 */
const OWNER_MEMBER = 0;
/** @var int 负责人编码 */
/** @var int 项目负责人编码 */
const OWNER_PRIMARY = 1;
/** @var int 副负责人编码 */
/** @var int 项目管理员编码 */
const OWNER_DEPUTY = 2;
/**
* 是否负责人owner=1
* 是否项目负责人owner=1
*/
public function isPrimaryOwner(): bool
{
@ -53,7 +53,7 @@ class ProjectUser extends AbstractModel
}
/**
* 是否副负责人owner=2
* 是否项目管理员owner=2
*/
public function isDeputyOwner(): bool
{
@ -61,7 +61,7 @@ class ProjectUser extends AbstractModel
}
/**
* 是否负责人(主或副
* 是否负责人(含项目管理员
*/
public function isOwner(): bool
{
@ -91,8 +91,8 @@ class ProjectUser extends AbstractModel
foreach ($list as $item) {
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
if ($row) {
// 已存在:仅当离职用户是owner=1时把接收人升为主
// 离职用户是owner=2时不传副给接收人spec不替补)
// 已存在:仅当离职用户是项目负责人owner=1时把接收人升为项目负责人
// 离职用户是项目管理员owner=2时不传项目管理员身份给接收人spec项目管理员不替补)
if ((int)$item->owner === self::OWNER_PRIMARY) {
$row->owner = self::OWNER_PRIMARY;
}
@ -100,7 +100,7 @@ class ProjectUser extends AbstractModel
$row->save();
$item->delete();
} else {
// 不存在:转移时如果离职用户是副,降级为普通成员(不带副身份过户给接收人)
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人)
if ((int)$item->owner === self::OWNER_DEPUTY) {
$item->owner = self::OWNER_MEMBER;
}

View File

@ -55,7 +55,7 @@ class UserDepartment extends AbstractModel
}
/**
* 副负责人 userid 列表
* 部门管理员 userid 列表
* @return array
*/
public function getDeputyUseridsAttribute(): array
@ -71,7 +71,7 @@ class UserDepartment extends AbstractModel
}
/**
* 是否负责人(与 owner_userid 一致)
* 是否部门负责人(与 owner_userid 一致)
*/
public function isPrimaryOwner($userid): bool
{
@ -82,7 +82,7 @@ class UserDepartment extends AbstractModel
}
/**
* 是否副负责人(在 user_department_owners 表里)
* 是否部门管理员(在 user_department_owners 表里)
*/
public function isDeputyOwner($userid): bool
{
@ -96,7 +96,7 @@ class UserDepartment extends AbstractModel
}
/**
* 是否负责人(主或副
* 是否负责人(含部门管理员
*/
public function isOwner($userid): bool
{
@ -127,7 +127,7 @@ class UserDepartment extends AbstractModel
$dialog->owner_id = $this->owner_userid;
if ($dialog->save()) {
$dialog->joinGroup($this->owner_userid, 0, true);
// 同步 role主 role=0、新主 role=1 role=2 保留不动)
// 同步 role负责人 role=0、新负责人 role=1部门管理员 role=2 保留不动)
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
@ -156,9 +156,9 @@ class UserDepartment extends AbstractModel
$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 显式重新任命)
// 同步 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)
@ -214,10 +214,10 @@ class UserDepartment extends AbstractModel
}
/**
* 任命副负责人
* - 副自动加入 users.department成为部门成员与主对齐)
* - 自动加入部门群 + role=2
* - 幂等(已是不报错)
* 任命部门管理员
* - 部门管理员自动加入 users.department成为部门成员与负责人对齐)
* - 部门管理员自动加入部门群 + role=2
* - 幂等(已是部门管理员不报错)
*
* @param int $userid
* @return void
@ -237,13 +237,13 @@ class UserDepartment extends AbstractModel
}
AbstractModel::transaction(function () use ($userid, $user) {
// 写unique key 自动幂等)
// 写部门管理员unique key 自动幂等)
\DB::table('user_department_owners')->insertOrIgnore([
'department_id' => $this->id,
'userid' => $userid,
]);
// 加入 users.department成为部门成员对齐)
// 加入 users.department成为部门成员负责人对齐)
$userDeptIds = $user->department; // accessor 返回数组
if (!in_array($this->id, $userDeptIds)) {
$userDeptIds = array_merge($userDeptIds, [$this->id]);
@ -251,7 +251,7 @@ class UserDepartment extends AbstractModel
$user->save();
}
// 加入部门群 + 设 role=2
// 加部门管理员入部门群 + 设 role=2
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
@ -270,9 +270,9 @@ class UserDepartment extends AbstractModel
}
/**
* 罢免副负责人
* - 表记录
* - users.department 移除该部门 ID"离开部门"对齐)
* 罢免部门管理员
* - 部门管理员表记录
* - users.department 移除该部门 ID负责人"离开部门"对齐)
* - 退出部门群(成员关系=群关系一致)
* - 幂等
*
@ -341,7 +341,7 @@ 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();
@ -355,7 +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) {
@ -364,7 +364,7 @@ class UserDepartment extends AbstractModel
]);
}
});
// 副离职清理(新增):直接删除离职用户的所有副记录
// 部门管理员离职清理(新增):直接删除离职用户的所有部门管理员记录
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群role 随成员关系一起消失
\DB::table('user_department_owners')
->where('userid', $originalUserid)

View File

@ -520,7 +520,7 @@ class WebSocketDialog extends AbstractModel
foreach ($list as $item) {
if ($checkDelete) {
if ($type === 'remove') {
// 移出时:如果是全员群仅允许管理员操作,其他群主/副群主/邀请人可以操作
// 移出时:如果是全员群仅允许管理员操作,其他群主/群管理员/邀请人可以操作
if ($this->group_type === 'all') {
User::auth("admin");
} else {
@ -529,12 +529,12 @@ class WebSocketDialog extends AbstractModel
if ($actor <= 0) {
throw new ApiException('只有群主或邀请人可以移出成员');
}
// 主群主、副群主、邀请人可移出
// 群主、群管理员、邀请人可移出
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
if (!$allowedActor) {
throw new ApiException('只有群主或邀请人可以移出成员');
}
// 副群主不能移出主群主或其他副群主
// 群管理员不能移出群主或其他群管理员
if ($this->isDeputyOwner($actor)) {
$targetIsOwner = $this->isPrimaryOwner($item->userid) || $this->isDeputyOwner($item->userid);
if ($targetIsOwner) {
@ -659,7 +659,7 @@ class WebSocketDialog extends AbstractModel
}
/**
* 是否群主(与 owner_id 一致)
* 是否群主(与 owner_id 一致)
*/
public function isPrimaryOwner($userid): bool
{
@ -667,7 +667,7 @@ class WebSocketDialog extends AbstractModel
}
/**
* 是否副群主(仅 web_socket_dialog_users.role=2
* 是否群管理员(仅 web_socket_dialog_users.role=2
*/
public function isDeputyOwner($userid): bool
{
@ -681,7 +681,7 @@ class WebSocketDialog extends AbstractModel
}
/**
* 是否群主(主或副
* 是否群主(含群管理员
*/
public function isOwner($userid): bool
{
@ -689,7 +689,7 @@ class WebSocketDialog extends AbstractModel
}
/**
* 副群主 userid 列表
* 群管理员 userid 列表
*
* @return array
*/

View File

@ -16,7 +16,7 @@ class AddRoleToWebSocketDialogUsers extends Migration
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=副群主');
->comment('0=普通成员 1=群主 2=群管理员');
$table->index(['dialog_id', 'role'], 'idx_dialog_role');
}
});

View File

@ -17,7 +17,7 @@ class CreateUserDepartmentOwnersTable extends Migration
Schema::create('user_department_owners', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('department_id')->comment('部门ID');
$table->unsignedBigInteger('userid')->comment('副负责人 userid');
$table->unsignedBigInteger('userid')->comment('部门管理员 userid');
$table->timestamp('created_at')->useCurrent();
$table->unique(['department_id', 'userid'], 'uniq_dept_user');
$table->index('userid', 'idx_userid');

View File

@ -774,7 +774,7 @@ export default {
},
deputyWaitDemote() {
// 使
// 使
const {deputy_userids = [], deputy_useridbak = []} = this.userData;
return deputy_useridbak.filter(id => !deputy_userids.includes(id));
},
@ -822,7 +822,7 @@ export default {
},
memberRowUncancelable() {
// +
// +
if (!this.projectData) return [];
const deputies = (this.userData && Array.isArray(this.userData.deputy_userids))
? this.userData.deputy_userids
@ -834,13 +834,13 @@ export default {
},
deputyRowUncancelable() {
// v-model
// v-model
if (!this.projectData) return [];
return [this.projectData.owner_userid];
},
deputyRowDisabledChoice() {
//
//
if (!this.projectData) return [];
return [this.projectData.owner_userid];
},
@ -1169,7 +1169,7 @@ export default {
this.handleColumnDebounce(100);
},
'userData.deputy_userids'(newDeputies) {
//
//
if (!Array.isArray(newDeputies) || !Array.isArray(this.userData.userids)) {
return;
}
@ -1507,7 +1507,7 @@ export default {
onUser() {
this.userLoad++;
// deputy userid
// deputy userid
const baseUserids = (this.userData.userids || []).slice();
const deputyUserids = (this.userData.deputy_userids || []).slice();
const mergedUserids = Array.from(new Set([...baseUserids, ...deputyUserids]));
@ -1516,7 +1516,7 @@ export default {
project_id: this.projectId,
userid: mergedUserids,
};
// deputy_userid/
// deputy_userid/
if (this.canManageDeputy) {
payload.deputy_userid = deputyUserids;
}

View File

@ -833,7 +833,7 @@ export default {
},
deputyDisabledChoice() {
//
//
return [
...(this.departmentData.owner_userid || []),
];
@ -1088,8 +1088,8 @@ export default {
});
$A.messageSuccess(res.msg);
// /
// departmentList
// /
// departmentList
let targetId = this.departmentData.id;
let oldDeputies = [];
if (targetId > 0) {

View File

@ -122,7 +122,7 @@ class MultiOwnerDepartmentTest extends TestCase
$member = $this->makeUser('d3_m@test.local');
$dept = $this->makeDepartment($owner->userid);
// 手动插入记录addDeputy 在 Task 5 才实现)
// 手动插入部门管理员记录addDeputy 在 Task 5 才实现)
DB::table('user_department_owners')->insert([
'department_id' => $dept->id,
'userid' => $deputy->userid,
@ -184,7 +184,7 @@ class MultiOwnerDepartmentTest extends TestCase
$deputy = $this->makeUser('d4b_dep@test.local');
$dept = $this->makeDepartment($oldOwner->userid);
// 加 deputy 入群 + 记录 + role=2pushMsg=false 跳过 Swoole
// 加 deputy 入群 + 部门管理员记录 + role=2pushMsg=false 跳过 Swoole
$dialog = WebSocketDialog::find($dept->dialog_id);
$dialog->joinGroup($deputy->userid, 0, null, false);
DB::table('user_department_owners')->insert([
@ -204,9 +204,9 @@ class MultiOwnerDepartmentTest extends TestCase
'owner_userid' => $newOwner->userid,
]);
// 表保留
// 部门管理员表保留
$this->assertContains($deputy->userid, $dept->fresh()->deputy_userids);
// role 保留
// 部门管理员 role 保留
$depRole = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $deputy->userid)->value('role');
$this->assertEquals(2, (int)$depRole);
@ -222,11 +222,11 @@ class MultiOwnerDepartmentTest extends TestCase
$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=2
$role = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $deputy->userid)->value('role');
$this->assertEquals(2, (int)$role);
@ -272,23 +272,23 @@ class MultiOwnerDepartmentTest extends TestCase
$dept = $this->makeDepartment($owner->userid);
$this->simulateAddDeputy($dept, $deputy->userid);
// 任命后应该入 users.department 并加入部门群
$this->assertContains($dept->id, User::find($deputy->userid)->department, '任命后应加入 users.department');
// 任命后部门管理员应该入 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 移除');
// 罢免后从 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, '罢免后应退出部门群(成员关系=群关系)');
$this->assertFalse($exists, '罢免部门管理员后应退出部门群(成员关系=群关系)');
}
public function test_delDeputy_idempotent_for_non_deputy()
@ -297,7 +297,7 @@ class MultiOwnerDepartmentTest extends TestCase
$member = $this->makeUser('d6b_m@test.local');
$dept = $this->makeDepartment($owner->userid);
// member 不是,调 delDeputy 不应抛错
// member 不是部门管理员,调 delDeputy 不应抛错
$this->simulateDelDeputy($dept, $member->userid);
$this->assertTrue(true);
}
@ -326,15 +326,15 @@ class MultiOwnerDepartmentTest extends TestCase
UserDepartment::transfer($departing->userid, $receiver->userid);
// 离职的记录已删
// 离职的部门管理员记录已删
$this->assertNotContains($departing->userid, $dept->fresh()->deputy_userids);
// receiver 没有继承身份
// 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);
@ -346,7 +346,7 @@ class MultiOwnerDepartmentTest extends TestCase
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');
@ -365,7 +365,7 @@ class MultiOwnerDepartmentTest extends TestCase
$parentId = $parent->id;
$childId = $child->id;
$parent->deleteDepartment(); // 递归删子部门 + 清各自
$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

@ -48,7 +48,7 @@ class MultiOwnerGroupTest extends TestCase
$member = $this->makeUser('m2@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy->userid, $member->userid]);
// 手工把 deputy 设为副群主
// 手工把 deputy 设为群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
@ -111,13 +111,13 @@ class MultiOwnerGroupTest extends TestCase
->where('userid', $deputy->userid)
->update(['role' => 2]);
// 模拟副群主退群pushMsg=false 跳过 Swoole 推送,仅验证 DB 状态)
// 模拟群管理员退群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->assertFalse($exists, '退群后群管理员记录应被删除');
$this->assertNotContains($deputy->userid, $dialog->fresh()->deputy_ids);
}
@ -358,13 +358,13 @@ class MultiOwnerGroupTest extends TestCase
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) {
@ -389,9 +389,9 @@ class MultiOwnerGroupTest extends TestCase
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'] ?? ''));
$this->assertTrue($result['allowed'], '群管理员应能移出普通成员,错误:' . ($result['error'] ?? ''));
// 验证实际移出操作checkDelete=false 绕过 auth直接测试 DB 状态)
$dialog->exitGroup($member->userid, 'remove', false, false);
@ -408,9 +408,9 @@ class MultiOwnerGroupTest extends TestCase
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->assertFalse($result['allowed'], '群管理员不应能移出群主');
$this->assertNotNull($result['error']);
}
@ -424,9 +424,9 @@ class MultiOwnerGroupTest extends TestCase
->whereIn('userid', [$deputy1->userid, $deputy2->userid])
->update(['role' => 2]);
// 验证权限逻辑:副群主不可移出其他副群主
// 验证权限逻辑:群管理员不可移出其他群管理员
$result = $this->simulateRemovePermission($dialog, $deputy1->userid, $deputy2->userid);
$this->assertFalse($result['allowed'], '副群主不应能移出其他副群主');
$this->assertFalse($result['allowed'], '群管理员不应能移出其他群管理员');
$this->assertEquals('群管理员不能移出群主或其他群管理员', $result['error']);
}
@ -466,11 +466,11 @@ class MultiOwnerGroupTest extends TestCase
}
/**
* 验证离职移交时副群主角色被正确清除。
* 验证离职移交时群管理员角色被正确清除。
*
* UserTransfer::exitDialog() 对离职用户调用 exitGroup($original_userid, 'remove', false, false)
* exitGroup 内部直接 hard-delete web_socket_dialog_users 记录($item->delete()
* 因此副群主role 随记录一起消失,无需额外逻辑。
* 因此群管理员role 随记录一起消失,无需额外逻辑。
*
* 本测试直接调用 exitDialog()(通过 UserTransfer 实例),绕过 start() 中的项目/任务/文件迁移,
* 以确保在无 Swoole 推送的 PHPUnit 环境中可以正常运行。
@ -482,13 +482,13 @@ class MultiOwnerGroupTest extends TestCase
$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, '前置条件:离职用户应是副群主');
$this->assertContains($departing->userid, $dialog->fresh()->deputy_ids, '前置条件:离职用户应是群管理员');
// 通过 UserTransfer 触发 exitDialog使用正确字段名 original_userid / new_userid
$transfer = \App\Models\UserTransfer::createInstance([
@ -500,14 +500,14 @@ class MultiOwnerGroupTest extends TestCase
$freshDialog = $dialog->fresh();
// 离职用户不应再出现在副群主列表中
$this->assertNotContains($departing->userid, $freshDialog->deputy_ids, '离职用户不应留在副群主列表');
// 离职用户不应再出现在群管理员列表中
$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, '接收方不应自动继承副群主角色');
// 接收方不应自动继承群管理员角色
$this->assertNotContains($receiver->userid, $freshDialog->deputy_ids, '接收方不应自动继承群管理员角色');
}
}

View File

@ -117,17 +117,17 @@ class MultiOwnerProjectTest extends TestCase
}
/**
* 模拟合并后的 ProjectController::user() 端点:同步成员 + 副负责人
* 模拟合并后的 ProjectController::user() 端点:同步成员 + 项目管理员
*
* @param Project $project 项目实例
* @param int $callerUserid 调用方 userid用于权限判断
* @param int[] $userids 最终成员完整列表(必须包含负责人)
* @param int[]|null $deputyUserids 最终副负责人完整列表null 表示不设置(沿用既有副
* @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)) {
@ -474,12 +474,12 @@ class MultiOwnerProjectTest extends TestCase
\App\Models\ProjectUser::transfer($departing->userid, $receiver->userid);
// 离职的已不在 project_users
// 离职的项目管理员已不在 project_users
$this->assertFalse(
ProjectUser::where('project_id', $project->id)
->where('userid', $departing->userid)->exists()
);
// receiver 没有继承身份
// 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);