feat(system): 用户-项目绑定管理 API + 顶栏项目下拉修正

- 新增 UserProjectService/ServiceImpl/Controller:给用户分配项目、给项目分配成员
  幂等覆盖写入(diff 出增删),参考 PermissionServiceImpl.assignUserRole 模式
- 自踢守卫:禁止用户把自己从当前正在访问的项目中移除
- 超管守卫:assignProjectUsers 拒绝移除持有超管角色的用户(用 RoleService.hasAnySuperAdmin 判别,非 userId==1)
- ProjectController.simple-list 改为只返回"当前用户授权且启用"的项目(修 bug:原返回整租户启用项目,会让顶栏下拉看到无权访问的项目)
- 新增 /system/project/all-simple-list:管理员分配场景的全量项目下拉,权限复用 system:project:query
- ProjectService.deleteProject 加 @Transactional,同事务内级联软删 system_user_project
- 新增两条菜单权限种子 SQL,parent_id 子查询动态定位:
  * system:user:assign-project
  * system:project:assign-user
- 新增错误码 USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT / USER_PROJECT_CANNOT_REMOVE_SUPER_ADMIN

设计文档:docs/design/2026-04-23-user-project-binding.md(在前端仓库)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 14:48:57 +08:00
parent b91a366f51
commit 88cab42a9c
10 changed files with 463 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ import com.viewsh.framework.common.enums.CommonStatusEnum;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.framework.common.util.object.BeanUtils;
import com.viewsh.framework.security.core.util.SecurityFrameworkUtils;
import com.viewsh.module.system.controller.admin.project.vo.ProjectPageReqVO;
import com.viewsh.module.system.controller.admin.project.vo.ProjectRespVO;
import com.viewsh.module.system.controller.admin.project.vo.ProjectSaveReqVO;
@@ -72,9 +73,21 @@ public class ProjectController {
}
@GetMapping("/simple-list")
@Operation(summary = "获取项目精简信息列表", description = "只包含被开启的项目,用于下拉选择")
@Operation(summary = "获取当前用户授权的项目精简列表",
description = "只包含当前登录用户授权且启用的项目,用于顶栏项目切换器")
@PreAuthorize("@ss.hasPermission('system:project:query')")
public CommonResult<List<ProjectRespVO>> getProjectSimpleList() {
Long userId = SecurityFrameworkUtils.getLoginUserId();
List<ProjectDO> list = projectService.getAuthorizedEnabledProjects(userId);
return success(convertList(list, project ->
new ProjectRespVO().setId(project.getId()).setName(project.getName()).setCode(project.getCode())));
}
@GetMapping("/all-simple-list")
@Operation(summary = "获取本租户全部启用项目(管理员分配场景用)",
description = "不做用户授权过滤,供管理员在为别人分配项目的下拉里使用。权限点复用 system:project:query。")
@PreAuthorize("@ss.hasPermission('system:project:query')")
public CommonResult<List<ProjectRespVO>> getAllProjectSimpleList() {
List<ProjectDO> list = projectService.getProjectListByStatus(CommonStatusEnum.ENABLE.getStatus());
return success(convertList(list, project ->
new ProjectRespVO().setId(project.getId()).setName(project.getName()).setCode(project.getCode())));

View File

@@ -0,0 +1,73 @@
package com.viewsh.module.system.controller.admin.project;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.system.controller.admin.project.vo.UserProjectAssignProjectUsersReqVO;
import com.viewsh.module.system.controller.admin.project.vo.UserProjectAssignUserProjectsReqVO;
import com.viewsh.module.system.service.project.UserProjectService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Set;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 用户与项目绑定管理
*
* 提供两个视角的覆盖式分配:
* - 用户视角:/assign-user-projects 给某用户分配项目集合
* - 项目视角:/assign-project-users 给某项目分配成员集合
*
* @author lzh
*/
@Tag(name = "管理后台 - 用户与项目绑定")
@RestController
@RequestMapping("/system/user-project")
public class UserProjectController {
@Resource
private UserProjectService userProjectService;
@PostMapping("/assign-user-projects")
@Operation(summary = "给用户分配项目(覆盖写入)")
@PreAuthorize("@ss.hasPermission('system:user:assign-project')")
public CommonResult<Boolean> assignUserProjects(@Valid @RequestBody UserProjectAssignUserProjectsReqVO reqVO) {
userProjectService.assignUserProjects(reqVO.getUserId(), reqVO.getProjectIds());
return success(true);
}
@PostMapping("/assign-project-users")
@Operation(summary = "给项目分配成员(覆盖写入)")
@PreAuthorize("@ss.hasPermission('system:project:assign-user')")
public CommonResult<Boolean> assignProjectUsers(@Valid @RequestBody UserProjectAssignProjectUsersReqVO reqVO) {
userProjectService.assignProjectUsers(reqVO.getProjectId(), reqVO.getUserIds());
return success(true);
}
@GetMapping("/list-project-ids-by-user")
@Operation(summary = "查询某用户已绑定的项目编号集合")
@Parameter(name = "userId", description = "用户编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('system:user:assign-project')")
public CommonResult<Set<Long>> getProjectIdsByUserId(@RequestParam("userId") Long userId) {
return success(userProjectService.getProjectIdsByUserId(userId));
}
@GetMapping("/list-user-ids-by-project")
@Operation(summary = "查询某项目下的用户编号集合")
@Parameter(name = "projectId", description = "项目编号", required = true, example = "101")
@PreAuthorize("@ss.hasPermission('system:project:assign-user')")
public CommonResult<Set<Long>> getUserIdsByProjectId(@RequestParam("projectId") Long projectId) {
return success(userProjectService.getUserIdsByProjectId(projectId));
}
}

View File

@@ -0,0 +1,26 @@
package com.viewsh.module.system.controller.admin.project.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Collections;
import java.util.Set;
/**
* 管理后台 - 给项目分配成员(覆盖写入) Req VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 给项目分配成员(覆盖写入) Req VO")
@Data
public class UserProjectAssignProjectUsersReqVO {
@Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "101")
@NotNull(message = "项目编号不能为空")
private Long projectId;
@Schema(description = "用户编号集合(传空集即清空所有成员)", example = "11,12,13")
private Set<Long> userIds = Collections.emptySet();
}

View File

@@ -0,0 +1,26 @@
package com.viewsh.module.system.controller.admin.project.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Collections;
import java.util.Set;
/**
* 管理后台 - 给用户分配项目(覆盖写入) Req VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 给用户分配项目(覆盖写入) Req VO")
@Data
public class UserProjectAssignUserProjectsReqVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "用户编号不能为空")
private Long userId;
@Schema(description = "项目编号集合(传空集即清空所有分配)", example = "1,2,3")
private Set<Long> projectIds = Collections.emptySet();
}

View File

@@ -110,4 +110,12 @@ public interface ProjectService {
*/
Long getDefaultProjectId(Long userId);
/**
* 获得指定用户"授权且启用"的项目列表(用于顶栏项目切换器的下拉数据源)
*
* @param userId 用户编号
* @return 授权启用的项目列表
*/
List<ProjectDO> getAuthorizedEnabledProjects(Long userId);
}

View File

@@ -16,8 +16,10 @@ import com.viewsh.module.system.service.user.AdminUserService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.Collections;
import java.util.List;
import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -72,10 +74,15 @@ public class ProjectServiceImpl implements ProjectService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteProject(Long id) {
// 校验存在
validateProjectExists(id);
// 删除
// 级联软删用户-项目关联(同一事务内,任一失败全部回滚)
userProjectMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProjectDO>()
.eq(UserProjectDO::getProjectId, id));
// 删除项目
projectMapper.deleteById(id);
}
@@ -145,6 +152,18 @@ public class ProjectServiceImpl implements ProjectService {
return CollectionUtils.convertList(userProjects, UserProjectDO::getProjectId);
}
@Override
public List<ProjectDO> getAuthorizedEnabledProjects(Long userId) {
List<Long> authorizedIds = getAuthorizedProjectIds(userId);
if (CollUtil.isEmpty(authorizedIds)) {
return Collections.emptyList();
}
List<ProjectDO> projects = projectMapper.selectByIds(authorizedIds);
return projects.stream()
.filter(p -> CommonStatusEnum.ENABLE.getStatus().equals(p.getStatus()))
.toList();
}
@Override
public Long getDefaultProjectId(Long userId) {
List<Long> authorizedProjectIds = getAuthorizedProjectIds(userId);

View File

@@ -0,0 +1,62 @@
package com.viewsh.module.system.service.project;
import java.util.Set;
/**
* 用户-项目关联 Service 接口
*
* 管理 {@link com.viewsh.module.system.dal.dataobject.project.UserProjectDO} 的增删查。
*
* @author lzh
*/
public interface UserProjectService {
/**
* 覆盖式分配某用户的项目集合
* 传入全集,内部 diff 出新增和移除。
*
* @param userId 用户编号
* @param projectIds 期望的项目编号集合(传空集即清空该用户所有项目分配)
*/
void assignUserProjects(Long userId, Set<Long> projectIds);
/**
* 覆盖式分配某项目的成员集合
* 传入全集,内部 diff 出新增和移除。
*
* @param projectId 项目编号
* @param userIds 期望的用户编号集合(传空集即清空该项目所有成员)
*/
void assignProjectUsers(Long projectId, Set<Long> userIds);
/**
* 查询某用户已绑定的项目编号集合
*
* @param userId 用户编号
* @return 项目编号集合
*/
Set<Long> getProjectIdsByUserId(Long userId);
/**
* 查询某项目下绑定的用户编号集合
*
* @param projectId 项目编号
* @return 用户编号集合
*/
Set<Long> getUserIdsByProjectId(Long projectId);
/**
* 批量清理项目下所有绑定(供项目删除时级联调用)
*
* @param projectId 项目编号
*/
void deleteByProjectId(Long projectId);
/**
* 批量清理用户的所有绑定(供用户删除时级联调用)
*
* @param userId 用户编号
*/
void deleteByUserId(Long userId);
}

View File

@@ -0,0 +1,173 @@
package com.viewsh.module.system.service.project;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.viewsh.framework.common.util.collection.CollectionUtils;
import com.viewsh.framework.security.core.util.SecurityFrameworkUtils;
import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
import com.viewsh.module.system.dal.dataobject.project.UserProjectDO;
import com.viewsh.module.system.dal.mysql.project.UserProjectMapper;
import com.viewsh.module.system.service.permission.PermissionService;
import com.viewsh.module.system.service.permission.RoleService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.viewsh.framework.common.util.collection.CollectionUtils.convertSet;
import static com.viewsh.module.system.enums.ErrorCodeConstants.USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT;
import static com.viewsh.module.system.enums.ErrorCodeConstants.USER_PROJECT_CANNOT_REMOVE_SUPER_ADMIN;
/**
* 用户-项目关联 Service 实现
*
* @author lzh
*/
@Service
@Validated
public class UserProjectServiceImpl implements UserProjectService {
@Resource
private UserProjectMapper userProjectMapper;
@Resource
@Lazy // 避免与 PermissionService 循环依赖
private PermissionService permissionService;
@Resource
@Lazy
private RoleService roleService;
@Override
@Transactional(rollbackFor = Exception.class)
public void assignUserProjects(Long userId, Set<Long> projectIds) {
Set<Long> targetIds = CollUtil.emptyIfNull(projectIds);
// 1. 查当前绑定
Set<Long> dbIds = convertSet(userProjectMapper.selectListByUserId(userId),
UserProjectDO::getProjectId);
// 2. 自踢守卫:如果调用者就是 userId 本人,且期望集合不包含当前正在访问的项目,拒绝
Long currentLoginUserId = SecurityFrameworkUtils.getLoginUserId();
Long currentProjectId = ProjectContextHolder.getProjectId();
if (currentLoginUserId != null && currentLoginUserId.equals(userId)
&& currentProjectId != null
&& dbIds.contains(currentProjectId)
&& !targetIds.contains(currentProjectId)) {
throw exception(USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT);
}
// 3. 计算增删
Collection<Long> toInsert = CollUtil.subtract(targetIds, dbIds);
Collection<Long> toDelete = CollUtil.subtract(dbIds, targetIds);
// 4. 执行
if (CollUtil.isNotEmpty(toInsert)) {
userProjectMapper.insertBatch(CollectionUtils.convertList(toInsert, projectId -> {
UserProjectDO entity = new UserProjectDO();
entity.setUserId(userId);
entity.setProjectId(projectId);
return entity;
}));
}
if (CollUtil.isNotEmpty(toDelete)) {
userProjectMapper.delete(Wrappers.<UserProjectDO>lambdaQuery()
.eq(UserProjectDO::getUserId, userId)
.in(UserProjectDO::getProjectId, toDelete));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void assignProjectUsers(Long projectId, Set<Long> userIds) {
Set<Long> targetIds = CollUtil.emptyIfNull(userIds);
// 1. 查当前成员
Set<Long> dbIds = convertSet(userProjectMapper.selectListByProjectId(projectId),
UserProjectDO::getUserId);
// 2. 算要移除的用户
Collection<Long> toDelete = CollUtil.subtract(dbIds, targetIds);
// 3. 超管守卫:被移除的用户里若有超管角色,拒绝
if (CollUtil.isNotEmpty(toDelete)) {
for (Long uid : toDelete) {
Set<Long> userRoleIds = permissionService.getUserRoleIdListByUserId(uid);
if (roleService.hasAnySuperAdmin(userRoleIds)) {
throw exception(USER_PROJECT_CANNOT_REMOVE_SUPER_ADMIN);
}
}
}
// 4. 自踢守卫:若当前登录人在被移除列表中,且当前项目就是目标项目,拒绝
Long currentLoginUserId = SecurityFrameworkUtils.getLoginUserId();
Long currentProjectId = ProjectContextHolder.getProjectId();
if (currentLoginUserId != null && currentProjectId != null
&& projectId.equals(currentProjectId)
&& toDelete.contains(currentLoginUserId)) {
throw exception(USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT);
}
// 5. 算要新增的用户
Collection<Long> toInsert = CollUtil.subtract(targetIds, dbIds);
// 6. 执行
if (CollUtil.isNotEmpty(toInsert)) {
userProjectMapper.insertBatch(CollectionUtils.convertList(toInsert, userId -> {
UserProjectDO entity = new UserProjectDO();
entity.setUserId(userId);
entity.setProjectId(projectId);
return entity;
}));
}
if (CollUtil.isNotEmpty(toDelete)) {
userProjectMapper.delete(Wrappers.<UserProjectDO>lambdaQuery()
.eq(UserProjectDO::getProjectId, projectId)
.in(UserProjectDO::getUserId, toDelete));
}
}
@Override
public Set<Long> getProjectIdsByUserId(Long userId) {
if (userId == null) {
return Collections.emptySet();
}
List<UserProjectDO> list = userProjectMapper.selectListByUserId(userId);
return convertSet(list, UserProjectDO::getProjectId);
}
@Override
public Set<Long> getUserIdsByProjectId(Long projectId) {
if (projectId == null) {
return Collections.emptySet();
}
List<UserProjectDO> list = userProjectMapper.selectListByProjectId(projectId);
return convertSet(list, UserProjectDO::getUserId);
}
@Override
public void deleteByProjectId(Long projectId) {
if (projectId == null) {
return;
}
userProjectMapper.delete(Wrappers.<UserProjectDO>lambdaQuery()
.eq(UserProjectDO::getProjectId, projectId));
}
@Override
public void deleteByUserId(Long userId) {
if (userId == null) {
return;
}
userProjectMapper.delete(Wrappers.<UserProjectDO>lambdaQuery()
.eq(UserProjectDO::getUserId, userId));
}
}