feat(guidelines): 更新工作指引并添加批量成员管理功能

- 简化 AGENTS.md 内容,统一引用 CLAUDE.md 作为主要指引
- 更新 CLAUDE.md 中的工作方式和验证流程说明
- 添加产品和项目成员批量新增/移出的错误码定义
- 扩展系统角色 API 响应 DTO,增加可见性字段
- 实现产品团队成员批量新增和批量移出控制器接口
- 添加产品成员批量操作的服务层实现和业务校验逻辑
- 实现项目团队成员批量操作的相关控制器接口
- 优化产品成员列表查询,过滤不可见角色行
- 添加批量操作的审计日志记录功能
This commit is contained in:
2026-05-18 21:16:11 +08:00
parent 75886d7af5
commit 1ef86fc1cb
17 changed files with 625 additions and 285 deletions

View File

@@ -39,6 +39,12 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
ErrorCode PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_001_027, "原产品经理在该产品已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未在 system_role 找到,请联系管理员");
ErrorCode PRODUCT_MEMBER_USER_INVALID = new ErrorCode(1_008_001_029, "产品成员不是有效系统用户");
// 批量新增POST /project/product/{id}/members/batch专用同一请求内 userId 重复 / 经理拦截
ErrorCode PRODUCT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_001_030, "请勿在批量列表中重复添加同一成员");
ErrorCode PRODUCT_MEMBER_BATCH_MANAGER_NOT_ALLOWED = new ErrorCode(1_008_001_031, "批量新增不允许指定为经理,请通过编辑成员调整");
// 批量移出POST /project/product/{id}/members/batch/inactive专用同一请求内 memberId 重复
ErrorCode PRODUCT_MEMBER_BATCH_INACTIVE_MEMBER_DUPLICATE = new ErrorCode(1_008_001_032, "请勿在批量移出列表中重复指定同一成员");
// ========== 产品需求 1-008-002-000 ==========
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
@@ -103,6 +109,11 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
ErrorCode PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_002_033, "原项目经理在该项目已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未在 system_role 找到,请联系管理员");
// 批量新增POST /project/project/{id}/members/batch专用同一请求内 userId 重复 / 经理拦截
ErrorCode PROJECT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_002_035, "请勿在批量列表中重复添加同一成员");
ErrorCode PROJECT_MEMBER_BATCH_MANAGER_NOT_ALLOWED = new ErrorCode(1_008_002_036, "批量新增不允许指定为经理,请通过编辑成员调整");
// 批量移出POST /project/project/{id}/members/batch/inactive专用同一请求内 memberId 重复
ErrorCode PROJECT_MEMBER_BATCH_INACTIVE_MEMBER_DUPLICATE = new ErrorCode(1_008_002_037, "请勿在批量移出列表中重复指定同一成员");
// ========== 执行管理 1-008-003-000 ==========
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");

View File

@@ -1,6 +1,8 @@
package com.njcn.rdms.module.project.controller.admin.product;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberBatchCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberBatchInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
@@ -42,6 +44,14 @@ public class ProductMemberController {
return success(productMemberService.createProductMember(productId, reqVO));
}
@PostMapping("/{id}/members/batch")
@Operation(summary = "批量新增产品团队成员")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<List<Long>> batchCreateProductMembers(@PathVariable("id") Long productId,
@Valid @RequestBody ProductMemberBatchCreateReqVO reqVO) {
return success(productMemberService.batchCreateProductMembers(productId, reqVO));
}
@PutMapping("/{id}/members/{memberId}")
@Operation(summary = "调整产品团队成员角色")
public CommonResult<Boolean> updateProductMember(@PathVariable("id") Long productId,
@@ -60,4 +70,13 @@ public class ProductMemberController {
return success(true);
}
@PostMapping("/{id}/members/batch/inactive")
@Operation(summary = "批量移出产品团队成员")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<Boolean> batchInactiveProductMembers(@PathVariable("id") Long productId,
@Valid @RequestBody ProductMemberBatchInactiveReqVO reqVO) {
productMemberService.batchInactiveProductMembers(productId, reqVO);
return success(true);
}
}

View File

@@ -0,0 +1,44 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 产品团队成员批量新增 Request VO")
@Data
public class ProductMemberBatchCreateReqVO {
/**
* 批量上限沿用需求约定的 200超过走 Bean Validation 直接 400 拦截。
* 经理角色product_manager由 Service 兜底拦截,不在此体现。
*/
@Schema(description = "待新增的成员列表,长度 [1, 200]", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "请至少选择一名成员")
@Size(max = 200, message = "单次批量加入成员不能超过 200 人")
@Valid
private List<Item> members;
@Schema(description = "批量新增成员项")
@Data
public static class Item {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "用户编号不能为空")
private Long userId;
@Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002001")
@NotNull(message = "角色编号不能为空")
private Long roleId;
@Schema(description = "备注", example = "本次批量加入")
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}
}

View File

@@ -0,0 +1,27 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 产品团队成员批量移出 Request VO")
@Data
public class ProductMemberBatchInactiveReqVO {
/**
* 批量上限沿用需求约定的 200超过走 Bean Validation 直接 400 拦截。
* 经理、已失效、重复 memberId 等业务规则由 Service 兜底,不在此体现。
*/
@Schema(description = "待移出的成员关系 ID 列表,长度 [1, 200]", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "请至少选择一名成员")
@Size(max = 200, message = "单次批量移出成员不能超过 200 人")
private List<Long> memberIds;
@Schema(description = "移出原因,单一字符串应用于本批所有成员", example = "组织架构调整,本批成员退出当前产品团队")
@Size(max = 500, message = "移出原因长度不能超过500个字符")
private String reason;
}

View File

@@ -1,6 +1,8 @@
package com.njcn.rdms.module.project.controller.admin.project;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberBatchCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberBatchInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
@@ -42,6 +44,14 @@ public class ProjectMemberController {
return success(projectMemberService.createProjectMember(projectId, reqVO));
}
@PostMapping("/{id}/members/batch")
@Operation(summary = "批量新增项目成员")
@Parameter(name = "id", description = "项目编号", required = true, example = "1024")
public CommonResult<List<Long>> batchCreateProjectMembers(@PathVariable("id") Long projectId,
@Valid @RequestBody ProjectMemberBatchCreateReqVO reqVO) {
return success(projectMemberService.batchCreateProjectMembers(projectId, reqVO));
}
@PutMapping("/{id}/members/{memberId}")
@Operation(summary = "调整项目成员角色")
public CommonResult<Boolean> updateProjectMember(@PathVariable("id") Long projectId,
@@ -60,4 +70,13 @@ public class ProjectMemberController {
return success(true);
}
@PostMapping("/{id}/members/batch/inactive")
@Operation(summary = "批量移出项目成员")
@Parameter(name = "id", description = "项目编号", required = true, example = "1024")
public CommonResult<Boolean> batchInactiveProjectMembers(@PathVariable("id") Long projectId,
@Valid @RequestBody ProjectMemberBatchInactiveReqVO reqVO) {
projectMemberService.batchInactiveProjectMembers(projectId, reqVO);
return success(true);
}
}

View File

@@ -0,0 +1,44 @@
package com.njcn.rdms.module.project.controller.admin.project.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 项目成员批量新增 Request VO")
@Data
public class ProjectMemberBatchCreateReqVO {
/**
* 批量上限沿用需求约定的 200超过走 Bean Validation 直接 400 拦截。
* 经理角色project_manager由 Service 兜底拦截,不在此体现。
*/
@Schema(description = "待新增的成员列表,长度 [1, 200]", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "请至少选择一名成员")
@Size(max = 200, message = "单次批量加入成员不能超过 200 人")
@Valid
private List<Item> members;
@Schema(description = "批量新增成员项")
@Data
public static class Item {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "用户编号不能为空")
private Long userId;
@Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002001")
@NotNull(message = "角色编号不能为空")
private Long roleId;
@Schema(description = "备注", example = "本次批量加入")
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}
}

View File

@@ -0,0 +1,27 @@
package com.njcn.rdms.module.project.controller.admin.project.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 项目成员批量移出 Request VO")
@Data
public class ProjectMemberBatchInactiveReqVO {
/**
* 批量上限沿用需求约定的 200超过走 Bean Validation 直接 400 拦截。
* 经理、已失效、重复 memberId、仍担任未关闭执行负责人等业务规则由 Service 兜底,不在此体现。
*/
@Schema(description = "待移出的成员关系 ID 列表,长度 [1, 200]", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "请至少选择一名成员")
@Size(max = 200, message = "单次批量移出成员不能超过 200 人")
private List<Long> memberIds;
@Schema(description = "移出原因,单一字符串应用于本批所有成员", example = "组织架构调整,本批成员退出当前项目团队")
@Size(max = 500, message = "移出原因长度不能超过500个字符")
private String reason;
}

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberBatchCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberBatchInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
@@ -29,6 +31,15 @@ public interface ProductMemberService {
*/
Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO);
/**
* 批量新增产品团队成员(不承担经理交接语义;事务性入库,全部成功或整体回滚)。
*
* @param productId 产品编号
* @param reqVO 请求参数
* @return 新建的成员关系 ID 列表,顺序与入参 members 一致
*/
List<Long> batchCreateProductMembers(Long productId, ProductMemberBatchCreateReqVO reqVO);
/**
* 调整产品团队成员角色
*
@@ -47,4 +58,15 @@ public interface ProductMemberService {
*/
void inactiveProductMember(Long productId, Long memberId, ProductMemberInactiveReqVO reqVO);
/**
* 批量移出产品团队成员(事务性更新,全部成功或整体回滚)。
* <p>
* 业务校验:同请求 memberId 重复、不存在/不属于当前产品、已失效、产品经理均拒绝;
* 经理判定与单条 inactive 一致,按 product.managerUserId 比对。
*
* @param productId 产品编号
* @param reqVO 请求参数
*/
void batchInactiveProductMembers(Long productId, ProductMemberBatchInactiveReqVO reqVO);
}

View File

@@ -5,6 +5,8 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberBatchCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberBatchInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
@@ -32,6 +34,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -66,6 +69,14 @@ public class ProductMemberServiceImpl implements ProductMemberService {
ProductDO product = validateProductExists(productId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
// 过滤掉 visible=0 的角色行(业务自动赋予的角色,如创建者 / 隐式观察者;不在团队列表显示);
// visible=null 视同显示兼容旧数据未回填的场景。ACTIVE / INACTIVE 行一并过滤。
members = members.stream()
.filter(m -> {
ObjectRoleRespDTO role = roleMap.get(m.getRoleId());
return role == null || !Integer.valueOf(0).equals(role.getVisible());
})
.toList();
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
// 拆分 ACTIVE / INACTIVE
@@ -192,6 +203,85 @@ public class ProductMemberServiceImpl implements ProductMemberService {
return member.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId",
permission = ProductObjectConstants.PERMISSION_UPDATE)
public List<Long> batchCreateProductMembers(Long productId, ProductMemberBatchCreateReqVO reqVO) {
validateProductEditable(productId);
List<ProductMemberBatchCreateReqVO.Item> items = reqVO.getMembers();
// 同请求内 userId 重复一律拒绝user-level即使是不同角色也算重复——批量语义就是一人一行
Set<Long> seenUserIds = new HashSet<>(items.size());
for (ProductMemberBatchCreateReqVO.Item item : items) {
if (!seenUserIds.add(item.getUserId())) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_BATCH_USER_DUPLICATE);
}
}
// 批量校验用户存在/启用——产品域单条接口未做此校验(历史负债),批量接口按需求 §4 兜底
validateMemberUsers(seenUserIds);
List<Long> memberIds = new ArrayList<>(items.size());
LocalDateTime now = LocalDateTime.now();
for (ProductMemberBatchCreateReqVO.Item item : items) {
ObjectRoleRespDTO targetRole = validateProductRole(item.getRoleId());
if (isManagerRole(targetRole)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_BATCH_MANAGER_NOT_ALLOWED);
}
// user-level 拒绝:该用户在本产品下有任意 ACTIVE 行(含其他角色),不允许再批量加入
List<UserObjectRoleDO> activeRows = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, item.getUserId());
if (!activeRows.isEmpty()) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
}
// 多角色支持:按 (user, object, role) 三元组判存在INACTIVE 行复活,避免唯一索引 INSERT 冲突
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
item.getUserId(), targetRole.getId());
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
UserObjectRoleDO member;
String actionType;
if (existingMember == null) {
member = new UserObjectRoleDO();
member.setUserId(item.getUserId());
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
member.setObjectId(productId);
member.setRoleId(targetRole.getId());
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(item.getRemark()));
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
member = existingMember;
member.setRoleId(targetRole.getId());
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(item.getRemark()));
userObjectRoleMapper.updateById(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
}
writeMemberAuditLog(member, actionType, before, member, null);
memberIds.add(member.getId());
}
return memberIds;
}
private void validateMemberUsers(Set<Long> userIds) {
try {
Boolean valid = adminUserApi.validateUserList(new ArrayList<>(userIds)).getCheckedData();
if (Boolean.TRUE.equals(valid)) {
return;
}
} catch (RuntimeException ex) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_USER_INVALID);
}
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_USER_INVALID);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId",
@@ -257,6 +347,80 @@ public class ProductMemberServiceImpl implements ProductMemberService {
normalizeNullableText(reqVO.getReason()));
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId",
permission = ProductObjectConstants.PERMISSION_UPDATE)
public void batchInactiveProductMembers(Long productId, ProductMemberBatchInactiveReqVO reqVO) {
ProductDO product = validateProductEditable(productId);
List<Long> memberIds = reqVO.getMemberIds();
// 同请求 memberId 重复一律拒绝,避免对同一行重复 update 造成 audit 重复落库
Set<Long> seen = new HashSet<>(memberIds.size());
for (Long memberId : memberIds) {
if (!seen.add(memberId)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_BATCH_INACTIVE_MEMBER_DUPLICATE);
}
}
String reason = normalizeNullableText(reqVO.getReason());
LocalDateTime now = LocalDateTime.now();
// 收集批次摘要,循环外聚合写一条审计日志(需求 §6 口径)
List<UserObjectRoleDO> removed = new ArrayList<>(memberIds.size());
for (Long memberId : memberIds) {
UserObjectRoleDO member = validateMemberExists(productId, memberId);
if (!Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_ACTIVE);
}
// 经理判定沿用单条 inactive 口径:按 product.managerUserId 比对,不引入 role.code 判定避免分叉
if (Objects.equals(member.getUserId(), product.getManagerUserId())) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_MEMBER_NOT_ALLOW_REMOVE);
}
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_INACTIVE);
member.setLeftTime(now);
userObjectRoleMapper.updateById(member);
removed.add(member);
}
writeBatchInactiveAuditLog(productId, removed, reason);
}
/**
* 批量移出场景的聚合审计写一条对象维度日志bizType=product, bizId=productId
* fieldChanges 体现批量摘要 batchCount + members 数组,与 writeManagerChangeAuditLog 风格对齐。
* 单条 before/after 字段差异在聚合视图下故意丢弃,由读侧按需展开。
*/
private void writeBatchInactiveAuditLog(Long productId, List<UserObjectRoleDO> members, String reason) {
if (members.isEmpty()) {
return;
}
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(ProductObjectConstants.OBJECT_TYPE);
auditLog.setBizId(productId);
auditLog.setActionType(ObjectActivityConstants.MEMBER_ACTION_REMOVE);
auditLog.setFieldChanges(buildBatchInactiveFieldChanges(members));
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
private String buildBatchInactiveFieldChanges(List<UserObjectRoleDO> members) {
List<Map<String, Object>> memberSummaries = new ArrayList<>(members.size());
for (UserObjectRoleDO member : members) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("memberId", member.getId());
summary.put("userId", member.getUserId());
summary.put("roleId", member.getRoleId());
memberSummaries.add(summary);
}
Map<String, Object> root = new LinkedHashMap<>();
root.put("batchCount", members.size());
root.put("members", memberSummaries);
return JsonUtils.toJsonString(root);
}
private ProductDO validateProductExists(Long productId) {
if (productId == null) {
throw exception(ErrorCodeConstants.PRODUCT_NOT_EXISTS);

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberBatchCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberBatchInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
@@ -16,8 +18,28 @@ public interface ProjectMemberService {
Long createProjectMember(Long projectId, ProjectMemberSaveReqVO reqVO);
/**
* 批量新增项目成员(不承担经理交接语义;事务性入库,全部成功或整体回滚)。
*
* @param projectId 项目编号
* @param reqVO 请求参数
* @return 新建的成员关系 ID 列表,顺序与入参 members 一致
*/
List<Long> batchCreateProjectMembers(Long projectId, ProjectMemberBatchCreateReqVO reqVO);
void updateProjectMember(Long projectId, Long memberId, ProjectMemberUpdateReqVO reqVO);
void inactiveProjectMember(Long projectId, Long memberId, ProjectMemberInactiveReqVO reqVO);
/**
* 批量移出项目成员(事务性更新,全部成功或整体回滚)。
* <p>
* 业务校验:同请求 memberId 重复、不存在/不属于当前项目、已失效、项目经理、仍担任未关闭执行负责人均拒绝;
* 经理判定与单条 inactive 一致,按 project.managerUserId 比对。
*
* @param projectId 项目编号
* @param reqVO 请求参数
*/
void batchInactiveProjectMembers(Long projectId, ProjectMemberBatchInactiveReqVO reqVO);
}

View File

@@ -6,6 +6,8 @@ import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberBatchCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberBatchInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
@@ -34,6 +36,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -70,6 +73,14 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
ProjectDO project = validateProjectExists(projectId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
// 过滤掉 visible=0 的角色行(业务自动赋予的角色,如创建者 / 隐式观察者;不在团队列表显示);
// visible=null 视同显示兼容旧数据未回填的场景。ACTIVE / INACTIVE 行一并过滤。
members = members.stream()
.filter(m -> {
ObjectRoleRespDTO role = roleMap.get(m.getRoleId());
return role == null || !Integer.valueOf(0).equals(role.getVisible());
})
.toList();
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
// 拆分 ACTIVE / INACTIVE
@@ -188,6 +199,78 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
return member.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectObjectConstants.PERMISSION_MEMBER)
public List<Long> batchCreateProjectMembers(Long projectId, ProjectMemberBatchCreateReqVO reqVO) {
validateProjectEditable(projectId);
List<ProjectMemberBatchCreateReqVO.Item> items = reqVO.getMembers();
// 同请求内 userId 重复一律拒绝user-level即使是不同角色也算重复——批量语义就是一人一行
Set<Long> seenUserIds = new HashSet<>(items.size());
for (ProjectMemberBatchCreateReqVO.Item item : items) {
if (!seenUserIds.add(item.getUserId())) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_BATCH_USER_DUPLICATE);
}
}
// 批量校验用户存在/启用——按需求 §4 兜底
validateMemberUsers(seenUserIds);
List<Long> memberIds = new ArrayList<>(items.size());
LocalDateTime now = LocalDateTime.now();
for (ProjectMemberBatchCreateReqVO.Item item : items) {
ObjectRoleRespDTO targetRole = validateProjectRole(item.getRoleId());
if (isManagerRole(targetRole)) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_BATCH_MANAGER_NOT_ALLOWED);
}
// user-level 拒绝:该用户在本项目下有任意 ACTIVE 行(含其他角色),不允许再批量加入
List<UserObjectRoleDO> activeRows = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, item.getUserId());
if (!activeRows.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
}
// 多角色支持:按 (user, object, role) 三元组判存在INACTIVE 行复活,避免唯一索引 INSERT 冲突
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
item.getUserId(), targetRole.getId());
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
UserObjectRoleDO member = existingMember == null ? new UserObjectRoleDO() : existingMember;
member.setUserId(item.getUserId());
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
member.setObjectId(projectId);
member.setRoleId(targetRole.getId());
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(item.getRemark()));
String actionType;
if (existingMember == null) {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
userObjectRoleMapper.updateById(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
}
writeMemberAuditLog(member, actionType, before, member, null);
memberIds.add(member.getId());
}
return memberIds;
}
private void validateMemberUsers(Set<Long> userIds) {
try {
Boolean valid = adminUserApi.validateUserList(new ArrayList<>(userIds)).getCheckedData();
if (Boolean.TRUE.equals(valid)) {
return;
}
} catch (RuntimeException ex) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_USER_INVALID);
}
throw exception(ErrorCodeConstants.PROJECT_MEMBER_USER_INVALID);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
@@ -253,6 +336,47 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
normalizeNullableText(reqVO.getReason()));
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectObjectConstants.PERMISSION_MEMBER)
public void batchInactiveProjectMembers(Long projectId, ProjectMemberBatchInactiveReqVO reqVO) {
ProjectDO project = validateProjectEditable(projectId);
List<Long> memberIds = reqVO.getMemberIds();
// 同请求 memberId 重复一律拒绝,避免对同一行重复 update 造成 audit 重复落库
Set<Long> seen = new HashSet<>(memberIds.size());
for (Long memberId : memberIds) {
if (!seen.add(memberId)) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_BATCH_INACTIVE_MEMBER_DUPLICATE);
}
}
String reason = normalizeNullableText(reqVO.getReason());
LocalDateTime now = LocalDateTime.now();
// 收集批次摘要,循环外聚合写一条审计日志(需求 §6 口径)
List<UserObjectRoleDO> removed = new ArrayList<>(memberIds.size());
for (Long memberId : memberIds) {
UserObjectRoleDO member = validateMemberExists(projectId, memberId);
if (!Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_NOT_ACTIVE);
}
// 经理判定沿用单条 inactive 口径:按 project.managerUserId 比对,不引入 role.code 判定避免分叉
if (Objects.equals(member.getUserId(), project.getManagerUserId())) {
throw exception(ErrorCodeConstants.PROJECT_MANAGER_MEMBER_NOT_ALLOW_REMOVE);
}
// 与单条 inactive 一致:仍担任未关闭执行负责人的成员不能直接移出,需先完成执行负责人交接
validateNoOpenOwnedExecutions(projectId, member.getUserId());
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_INACTIVE);
member.setLeftTime(now);
userObjectRoleMapper.updateById(member);
removed.add(member);
}
writeBatchInactiveAuditLog(projectId, removed, reason);
}
private ProjectDO validateProjectExists(Long projectId) {
if (projectId == null) {
throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS);
@@ -485,6 +609,41 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
bizAuditLogMapper.insert(auditLog);
}
/**
* 批量移出场景的聚合审计写一条对象维度日志bizType=project, bizId=projectId
* fieldChanges 体现批量摘要 batchCount + members 数组,与 writeManagerChangeAuditLog 风格对齐。
* 单条 before/after 字段差异在聚合视图下故意丢弃,由读侧按需展开。
*/
private void writeBatchInactiveAuditLog(Long projectId, List<UserObjectRoleDO> members, String reason) {
if (members.isEmpty()) {
return;
}
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(ProjectObjectConstants.OBJECT_TYPE);
auditLog.setBizId(projectId);
auditLog.setActionType(ObjectActivityConstants.MEMBER_ACTION_REMOVE);
auditLog.setFieldChanges(buildBatchInactiveFieldChanges(members));
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
private String buildBatchInactiveFieldChanges(List<UserObjectRoleDO> members) {
List<Map<String, Object>> memberSummaries = new ArrayList<>(members.size());
for (UserObjectRoleDO member : members) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("memberId", member.getId());
summary.put("userId", member.getUserId());
summary.put("roleId", member.getRoleId());
memberSummaries.add(summary);
}
Map<String, Object> root = new LinkedHashMap<>();
root.put("batchCount", members.size());
root.put("members", memberSummaries);
return JsonUtils.toJsonString(root);
}
private String buildMemberFieldChanges(UserObjectRoleDO before, UserObjectRoleDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "userId", valueOf(before, UserObjectRoleDO::getUserId),