refactor(config): 将配置文件迁移至 Nacos 并优化通知事件
- 移除 application-dev.yaml 和 application-local.yaml 配置文件 - 将 Nacos 配置外置到根 pom 的 nacos.* 属性中 - 添加配置中心文件加载配置(rdms-common.yaml、rdms-common-secret.yaml) - 网关服务仅用 Nacos 做服务发现,不加载配置中心文件 - 为系统服务添加独有敏感配置(rdms-system-server-secret.yaml) - 为 mapper 添加 SQL 日志打印配置 - 为 NotifySendEvent 添加操作人用户编号字段用于排除通知 - 修改 NotifySendEvent 构造函数支持操作人排除参数 - 在通知监听器中实现操作人排除逻辑 - 添加操作人排除功能的单元测试
This commit is contained in:
@@ -113,7 +113,9 @@
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring.boot.version}</version>
|
||||
<configuration>
|
||||
<addResources>true</addResources>
|
||||
<!-- 必须为 false:addResources=true 会让 spring-boot:run 直接挂载源 resources 目录、
|
||||
跳过资源过滤,导致 application.yaml 里的 @nacos.xxx@ 占位符不被替换。 -->
|
||||
<addResources>false</addResources>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.common.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 当前登录用户在某对象(产品 / 项目)上担任的单个角色。
|
||||
* 供产品列表、项目列表等顶层主列表复用:每行返回登录用户自己的角色数组,无角色则为空数组。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 当前登录用户在该对象担任的角色")
|
||||
@Data
|
||||
public class CurrentUserRoleVO {
|
||||
|
||||
@Schema(description = "角色稳定标识(system_role.code)", requiredMode = Schema.RequiredMode.REQUIRED, example = "product_manager")
|
||||
private String roleKey;
|
||||
|
||||
@Schema(description = "角色中文名(system_role.name)", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品经理")
|
||||
private String roleName;
|
||||
|
||||
}
|
||||
@@ -72,8 +72,8 @@ public class ProductController {
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获取产品分页")
|
||||
public CommonResult<PageResult<ProductRespVO>> getProductPage(@Valid ProductPageReqVO pageReqVO) {
|
||||
PageResult<ProductDO> pageResult = productService.getProductPage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, ProductRespVO.class));
|
||||
// VO 组装(含当前用户角色聚合)已下沉到 Service,Controller 直接返回
|
||||
return success(productService.getProductPage(pageReqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/overview-summary")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
|
||||
|
||||
import com.njcn.rdms.module.project.controller.admin.common.vo.CurrentUserRoleVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 产品 Response VO")
|
||||
@Data
|
||||
@@ -39,4 +41,7 @@ public class ProductRespVO {
|
||||
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Schema(description = "当前登录用户在该产品担任的角色(无角色则为空数组)")
|
||||
private List<CurrentUserRoleVO> currentUserRoles;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import com.njcn.rdms.module.project.controller.admin.common.vo.CurrentUserRoleVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 项目 Response VO")
|
||||
@Data
|
||||
@@ -53,5 +55,7 @@ public class ProjectRespVO {
|
||||
private LocalDateTime createTime;
|
||||
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updateTime;
|
||||
@Schema(description = "当前登录用户在该项目担任的角色(无角色则为空数组)")
|
||||
private List<CurrentUserRoleVO> currentUserRoles;
|
||||
|
||||
}
|
||||
|
||||
@@ -125,4 +125,20 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
|
||||
.eq(UserObjectRoleDO::getStatus, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层列表「当前登录用户角色」用:查某用户在一批指定对象上的活跃角色行(status=0)。
|
||||
* 三条件精确命中,结果集只回当前页对象、与分页规模解耦,内存按 objectId 分组即可,无 N+1。
|
||||
*/
|
||||
default List<UserObjectRoleDO> selectActiveListByObjectTypeAndUserIdAndObjectIds(
|
||||
String objectType, Long userId, Collection<Long> objectIds) {
|
||||
if (objectIds == null || objectIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
|
||||
.eq(UserObjectRoleDO::getObjectType, objectType)
|
||||
.eq(UserObjectRoleDO::getUserId, userId)
|
||||
.in(UserObjectRoleDO::getObjectId, objectIds)
|
||||
.eq(UserObjectRoleDO::getStatus, 0));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,24 +24,33 @@ public class NotifySendEvent {
|
||||
private final Map<String, Object> params;
|
||||
/** 消息等级,见 {@link NotifyMessageLevelConstants}(默认普通) */
|
||||
private final Integer level;
|
||||
/** 操作人用户编号;非空时由监听器从接收人中排除(创建通知用),存量场景传 null 不排除 */
|
||||
private final Long operatorUserId;
|
||||
|
||||
private NotifySendEvent(Collection<Long> userIds, String templateCode,
|
||||
Map<String, Object> params, Integer level) {
|
||||
Map<String, Object> params, Integer level, Long operatorUserId) {
|
||||
this.userIds = userIds;
|
||||
this.templateCode = templateCode;
|
||||
this.params = params;
|
||||
this.level = level;
|
||||
this.operatorUserId = operatorUserId;
|
||||
}
|
||||
|
||||
/** 普通等级(兼容存量调用) */
|
||||
/** 普通等级、不排除操作人(兼容存量调用) */
|
||||
public static NotifySendEvent of(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
||||
return new NotifySendEvent(userIds, templateCode, params, NotifyMessageLevelConstants.NORMAL);
|
||||
return new NotifySendEvent(userIds, templateCode, params, NotifyMessageLevelConstants.NORMAL, null);
|
||||
}
|
||||
|
||||
/** 指定等级 */
|
||||
/** 指定等级、不排除操作人(兼容告警 / 工作报告催办) */
|
||||
public static NotifySendEvent of(Collection<Long> userIds, String templateCode,
|
||||
Map<String, Object> params, Integer level) {
|
||||
return new NotifySendEvent(userIds, templateCode, params, level);
|
||||
return new NotifySendEvent(userIds, templateCode, params, level, null);
|
||||
}
|
||||
|
||||
/** 指定等级 + 排除操作人(对象创建通知用) */
|
||||
public static NotifySendEvent of(Collection<Long> userIds, String templateCode,
|
||||
Map<String, Object> params, Integer level, Long operatorUserId) {
|
||||
return new NotifySendEvent(userIds, templateCode, params, level, operatorUserId);
|
||||
}
|
||||
|
||||
public Collection<Long> getUserIds() {
|
||||
@@ -60,4 +69,8 @@ public class NotifySendEvent {
|
||||
return level;
|
||||
}
|
||||
|
||||
public Long getOperatorUserId() {
|
||||
return operatorUserId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ public class NotifySendEventListener {
|
||||
targets.add(userId);
|
||||
}
|
||||
}
|
||||
// 排除操作人:创建通知不发给操作人自己(operatorUserId 为 null 的存量场景不排除)
|
||||
if (event.getOperatorUserId() != null) {
|
||||
targets.remove(event.getOperatorUserId());
|
||||
}
|
||||
if (targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,4 +28,19 @@ public class NotifyTemplateCodeConstants {
|
||||
/** 工作报告团队催办:主管催办下属提交指定周期工作报告 */
|
||||
public static final String WORK_REPORT_TEAM_REMIND = "work_report_team_remind";
|
||||
|
||||
/** 执行指派:创建执行后通知负责人 + 协办人 */
|
||||
public static final String EXECUTION_ASSIGNED = "execution_assigned";
|
||||
|
||||
/** 项目创建:通知项目团队成员(排除创建者角色) */
|
||||
public static final String PROJECT_CREATED = "project_created";
|
||||
|
||||
/** 产品创建:通知产品团队成员(排除创建者角色) */
|
||||
public static final String PRODUCT_CREATED = "product_created";
|
||||
|
||||
/** 项目需求创建:通知处理人 */
|
||||
public static final String PROJECT_REQUIREMENT_ASSIGNED = "project_requirement_assigned";
|
||||
|
||||
/** 产品需求创建:通知处理人 */
|
||||
public static final String PRODUCT_REQUIREMENT_ASSIGNED = "product_requirement_assigned";
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 项目 / 产品团队通知接收人解析:仅 active、排除创建者角色、按 userId 取代表角色(经理优先)+ 中文名 */
|
||||
@Component
|
||||
public class TeamNotifyRecipientResolver {
|
||||
|
||||
@Resource
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Resource
|
||||
private ObjectPermissionApi objectPermissionApi;
|
||||
|
||||
public List<TeamRecipient> resolveActiveExcludingCreator(
|
||||
String objectType, Long objectId, String creatorRoleCode, String managerRoleCode) {
|
||||
// 1. 取对象全部成员行,仅保留有效(active)成员
|
||||
List<UserObjectRoleDO> active = userObjectRoleMapper.selectListByObject(objectType, objectId).stream()
|
||||
.filter(r -> ObjectRoleConstants.MEMBER_STATUS_ACTIVE.equals(r.getStatus()))
|
||||
.collect(Collectors.toList());
|
||||
if (active.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 2. 批量取角色摘要(含 code / 中文名),构建 roleId → 角色 映射
|
||||
Set<Long> roleIds = active.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet());
|
||||
List<ObjectRoleRespDTO> roles = objectPermissionApi
|
||||
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, objectType)
|
||||
.getCheckedData();
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<Long, ObjectRoleRespDTO> roleMap = roles.stream()
|
||||
.collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
|
||||
|
||||
// 3. 逐行排除创建者角色行;同时有创建者+其它角色的用户仍按其它角色保留,纯创建者用户被剔除
|
||||
List<UserObjectRoleDO> kept = active.stream()
|
||||
.filter(r -> {
|
||||
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
|
||||
return role != null && !creatorRoleCode.equals(role.getCode());
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 4. 按 userId 聚合,取代表角色:经理优先,否则 roleId 升序兜底;保持首次出现顺序
|
||||
Map<Long, List<UserObjectRoleDO>> byUser = kept.stream()
|
||||
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
|
||||
List<TeamRecipient> result = new ArrayList<>();
|
||||
byUser.forEach((userId, rows) -> {
|
||||
UserObjectRoleDO primary = rows.stream()
|
||||
.filter(r -> {
|
||||
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
|
||||
return role != null && managerRoleCode.equals(role.getCode());
|
||||
})
|
||||
.findFirst()
|
||||
.orElseGet(() -> rows.stream()
|
||||
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
|
||||
.orElse(rows.get(0)));
|
||||
ObjectRoleRespDTO role = roleMap.get(primary.getRoleId());
|
||||
result.add(new TeamRecipient(userId, role == null ? "" : role.getName()));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
/** 团队通知接收人:用户编号 + 代表角色中文名 */
|
||||
public record TeamRecipient(Long userId, String roleName) {}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.njcn.rdms.module.project.service.member;
|
||||
|
||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.common.vo.CurrentUserRoleVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 批量解析「当前登录用户在一批同类型对象上担任的角色」。
|
||||
*
|
||||
* <p>供产品列表、项目列表等顶层主列表复用:对当前页只做两次额外查询
|
||||
*(角色行一次、角色名翻译一次),无 N+1。返回结果<strong>不做任何可见性过滤</strong>——
|
||||
* 创建者 / 隐式观察者等业务自动赋予角色也会返回(口径见
|
||||
* docs/superpowers/specs/2026-06-17-列表当前用户角色-design.md),因此不读 {@code visible}。
|
||||
*/
|
||||
@Component
|
||||
public class CurrentUserRoleResolver {
|
||||
|
||||
@Resource
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Resource
|
||||
private ObjectPermissionApi objectPermissionApi;
|
||||
|
||||
/**
|
||||
* @param objectType 对象类型(product / project)
|
||||
* @param userId 当前登录用户编号
|
||||
* @param objectIds 当前页对象编号集合
|
||||
* @param managerRoleCode 该对象类型的经理角色编码,用于排序时把经理置顶
|
||||
* @return objectId -> 角色列表(稳定顺序:经理优先,其余按 roleId 升序);
|
||||
* 无角色的对象不在返回 map 中,调用方按空数组处理
|
||||
*/
|
||||
public Map<Long, List<CurrentUserRoleVO>> resolveByObjectIds(
|
||||
String objectType, Long userId, Collection<Long> objectIds, String managerRoleCode) {
|
||||
if (userId == null || objectIds == null || objectIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<UserObjectRoleDO> rows = userObjectRoleMapper
|
||||
.selectActiveListByObjectTypeAndUserIdAndObjectIds(objectType, userId, objectIds);
|
||||
if (rows.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
// 一次性翻译涉及的角色(roleId -> code/name),避免逐对象翻译
|
||||
Set<Long> roleIds = rows.stream().map(UserObjectRoleDO::getRoleId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, ObjectRoleRespDTO> roleMap = loadRoleMap(roleIds, objectType);
|
||||
// 稳定排序:经理角色置顶,其余按 roleId 升序,避免每次返回顺序漂移
|
||||
Comparator<ObjectRoleRespDTO> order = Comparator
|
||||
.comparingInt((ObjectRoleRespDTO d) -> Objects.equals(managerRoleCode, d.getCode()) ? 0 : 1)
|
||||
.thenComparing(ObjectRoleRespDTO::getId, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||
return rows.stream()
|
||||
.filter(r -> r.getObjectId() != null && roleMap.containsKey(r.getRoleId()))
|
||||
.collect(Collectors.groupingBy(UserObjectRoleDO::getObjectId))
|
||||
.entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().stream()
|
||||
.map(r -> roleMap.get(r.getRoleId()))
|
||||
.sorted(order)
|
||||
.map(this::toVO)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private CurrentUserRoleVO toVO(ObjectRoleRespDTO dto) {
|
||||
CurrentUserRoleVO vo = new CurrentUserRoleVO();
|
||||
vo.setRoleKey(dto.getCode());
|
||||
vo.setRoleName(dto.getName());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Map<Long, ObjectRoleRespDTO> loadRoleMap(Set<Long> roleIds, String objectType) {
|
||||
if (roleIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<ObjectRoleRespDTO> roles = objectPermissionApi
|
||||
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, objectType)
|
||||
.getCheckedData();
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return roles.stream().collect(Collectors.toMap(
|
||||
ObjectRoleRespDTO::getId, Function.identity(), (a, b) -> a));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -44,14 +44,14 @@ public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignServ
|
||||
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
|
||||
Long managerRoleId = resolveRoleId(managerRoleCode, objectType);
|
||||
|
||||
insertOrReactivate(objectType, objectId, managerUserId, managerRoleId, "auto: manager");
|
||||
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
|
||||
insertOrReactivate(objectType, objectId, managerUserId, managerRoleId);
|
||||
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode) {
|
||||
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
|
||||
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
|
||||
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,7 +64,7 @@ public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignServ
|
||||
watcherUserIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.forEach(uid -> insertOrReactivate(objectType, objectId, uid, watcherRoleId, "auto: watcher"));
|
||||
.forEach(uid -> insertOrReactivate(objectType, objectId, uid, watcherRoleId));
|
||||
}
|
||||
|
||||
private Long resolveRoleId(String roleCode, String objectType) {
|
||||
@@ -86,7 +86,7 @@ public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignServ
|
||||
return role.getId();
|
||||
}
|
||||
|
||||
private void insertOrReactivate(String objectType, Long objectId, Long userId, Long roleId, String remark) {
|
||||
private void insertOrReactivate(String objectType, Long objectId, Long userId, Long roleId) {
|
||||
UserObjectRoleDO existing = userObjectRoleMapper
|
||||
.selectByObjectUserAndRole(objectType, objectId, userId, roleId);
|
||||
if (existing != null && Objects.equals(existing.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||
@@ -102,13 +102,15 @@ public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignServ
|
||||
row.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
row.setJoinedTime(now);
|
||||
row.setLeftTime(null);
|
||||
row.setRemark(remark);
|
||||
// 系统自动落的角色行不写备注,避免内部标记泄漏到团队成员列表的备注列
|
||||
row.setRemark(null);
|
||||
userObjectRoleMapper.insert(row);
|
||||
} else {
|
||||
existing.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
existing.setLeftTime(null);
|
||||
existing.setJoinedTime(now);
|
||||
existing.setRemark(remark);
|
||||
// 复活时一并清掉历史脏备注(旧版本写过 "auto: xxx")
|
||||
existing.setRemark(null);
|
||||
userObjectRoleMapper.updateById(existing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper;
|
||||
@@ -31,8 +33,12 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -127,6 +133,10 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
|
||||
private AttachmentFileIdResolver attachmentFileIdResolver;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||
@Resource
|
||||
private ProductMapper productMapper;
|
||||
@Resource
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
// ========== 需求增删改查 ==========
|
||||
|
||||
@@ -170,9 +180,30 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
|
||||
// 写入业务审计日志
|
||||
writeBizAuditLog(requirement, ACTION_CREATE, null, initialStatus,
|
||||
buildRequirementFieldChanges(null, requirement), null);
|
||||
// 创建后通知处理人
|
||||
publishRequirementAssignedNotify(requirement);
|
||||
return requirement.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建产品需求后通知处理人(currentHandlerUserId);提出人不发,操作人由监听器统一排除。
|
||||
* 仅 publish,发送由 NotifySendEventListener 事务提交后处理;通知失败不影响需求创建。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishRequirementAssignedNotify(ProductRequirementDO requirement) {
|
||||
Long handlerId = requirement.getCurrentHandlerUserId();
|
||||
if (handlerId == null) {
|
||||
return; // 无处理人则不发
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
ProductDO product = productMapper.selectById(requirement.getProductId());
|
||||
params.put("productName", product == null ? "" : product.getName());
|
||||
params.put("requirementTitle", requirement.getTitle());
|
||||
applicationEventPublisher.publishEvent(NotifySendEvent.of(
|
||||
List.of(handlerId), NotifyTemplateCodeConstants.PRODUCT_REQUIREMENT_ASSIGNED, params,
|
||||
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#updateReqVO.productId",
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductC
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
|
||||
@@ -74,10 +75,12 @@ public interface ProductService {
|
||||
/**
|
||||
* 获取产品分页
|
||||
*
|
||||
* <p>每个列表项含「当前登录用户在该产品担任的角色」({@code currentUserRoles}),无角色为空数组。
|
||||
*
|
||||
* @param pageReqVO 分页请求
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO);
|
||||
PageResult<ProductRespVO> getProductPage(ProductPageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 获取产品入口页概览统计
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
|
||||
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.common.vo.CurrentUserRoleVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextNavRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextProductRespVO;
|
||||
@@ -88,9 +89,15 @@ public class ProductServiceImpl implements ProductService {
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.member.CurrentUserRoleResolver currentUserRoleResolver;
|
||||
@Resource
|
||||
private ObjectDataScopeService objectDataScopeService;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||
@Resource
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.framework.notify.TeamNotifyRecipientResolver teamNotifyRecipientResolver;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -116,6 +123,8 @@ public class ProductServiceImpl implements ProductService {
|
||||
initDefaultRequirementModule(product);
|
||||
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
||||
buildProductFieldChanges(null, product), null);
|
||||
// 团队成员已全部落库,发「产品创建」通知(操作人由监听器统一排除)
|
||||
publishProductCreatedNotify(product);
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
@@ -177,9 +186,37 @@ public class ProductServiceImpl implements ProductService {
|
||||
// 8) 产品创建审计
|
||||
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
||||
buildProductFieldChanges(null, product), null);
|
||||
// 9) 团队成员已全部落库,发「产品创建」通知(操作人由监听器统一排除)
|
||||
publishProductCreatedNotify(product);
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建产品后逐成员发「产品创建」站内信:显式团队成员(排除创建者角色),每人带自己的角色名;
|
||||
* 操作人自己由监听器统一排除。仅 publish,发送由 NotifySendEventListener 事务提交后处理。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishProductCreatedNotify(ProductDO product) {
|
||||
Long operatorId = SecurityFrameworkUtils.getLoginUserId();
|
||||
List<com.njcn.rdms.module.project.framework.notify.TeamRecipient> recipients =
|
||||
teamNotifyRecipientResolver.resolveActiveExcludingCreator(
|
||||
ProductObjectConstants.OBJECT_TYPE, product.getId(),
|
||||
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode(),
|
||||
ProductObjectConstants.MANAGER_ROLE_CODE);
|
||||
for (com.njcn.rdms.module.project.framework.notify.TeamRecipient r : recipients) {
|
||||
// 逐成员一条事件:每人带自己的代表角色中文名
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("productName", product.getName());
|
||||
params.put("roleName", r.roleName());
|
||||
applicationEventPublisher.publishEvent(
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of(
|
||||
java.util.List.of(r.userId()),
|
||||
com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants.PRODUCT_CREATED,
|
||||
params,
|
||||
com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants.NORMAL, operatorId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验初始团队成员列表。
|
||||
*
|
||||
@@ -333,7 +370,7 @@ public class ProductServiceImpl implements ProductService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO) {
|
||||
public PageResult<ProductRespVO> getProductPage(ProductPageReqVO pageReqVO) {
|
||||
// 计算当前用户在 product 域的数据权限范围
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||
@@ -374,7 +411,30 @@ public class ProductServiceImpl implements ProductService {
|
||||
}
|
||||
// ALL 状态不加任何 scope 条件,直接查全部
|
||||
|
||||
return productMapper.selectPage(pageReqVO, wrapper);
|
||||
PageResult<ProductDO> doPage = productMapper.selectPage(pageReqVO, wrapper);
|
||||
return convertProductListWithRoles(doPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品分页 DO → RespVO,并回填「当前登录用户在该产品的角色」。
|
||||
* 角色聚合对当前页仅两次查询(角色行 + 角色名翻译),无 N+1;无角色的产品返回空数组。
|
||||
*/
|
||||
private PageResult<ProductRespVO> convertProductListWithRoles(PageResult<ProductDO> doPage) {
|
||||
List<ProductDO> list = doPage.getList();
|
||||
if (list == null || list.isEmpty()) {
|
||||
return new PageResult<>(new ArrayList<>(), doPage.getTotal());
|
||||
}
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
List<Long> objectIds = list.stream()
|
||||
.map(ProductDO::getId).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
Map<Long, List<CurrentUserRoleVO>> rolesByProduct = currentUserRoleResolver.resolveByObjectIds(
|
||||
ProductObjectConstants.OBJECT_TYPE, loginUserId, objectIds, ProductObjectConstants.MANAGER_ROLE_CODE);
|
||||
List<ProductRespVO> voList = list.stream().map(p -> {
|
||||
ProductRespVO vo = BeanUtils.toBean(p, ProductRespVO.class);
|
||||
vo.setCurrentUserRoles(rolesByProduct.getOrDefault(p.getId(), Collections.emptyList()));
|
||||
return vo;
|
||||
}).collect(Collectors.toList());
|
||||
return new PageResult<>(voList, doPage.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.Proj
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementModuleDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementStatusLogDO;
|
||||
@@ -33,6 +34,7 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransition
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementStatusLogMapper;
|
||||
@@ -42,9 +44,13 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -135,6 +141,10 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@Resource
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -171,9 +181,29 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
||||
|
||||
writeBizAuditLog(requirement, ACTION_CREATE, null, initialStatus,
|
||||
buildRequirementFieldChanges(null, requirement), null);
|
||||
publishRequirementAssignedNotify(requirement);
|
||||
return requirement.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目需求后通知处理人(currentHandlerUserId);提出人不发,操作人由监听器统一排除。
|
||||
* 仅 publish,发送由 NotifySendEventListener 事务提交后处理;通知失败不影响需求创建。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishRequirementAssignedNotify(ProjectRequirementDO requirement) {
|
||||
Long handlerId = requirement.getCurrentHandlerUserId();
|
||||
if (handlerId == null) {
|
||||
return; // 无处理人则不发
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
ProjectDO project = projectMapper.selectById(requirement.getProjectId());
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("requirementTitle", requirement.getTitle());
|
||||
applicationEventPublisher.publishEvent(NotifySendEvent.of(
|
||||
List.of(handlerId), NotifyTemplateCodeConstants.PROJECT_REQUIREMENT_ASSIGNED, params,
|
||||
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#updateReqVO.projectId",
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectRequirementConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.common.vo.CurrentUserRoleVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
|
||||
@@ -127,9 +128,17 @@ class ProjectServiceImpl implements ProjectService {
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.member.CurrentUserRoleResolver currentUserRoleResolver;
|
||||
@Resource
|
||||
private ObjectDataScopeService objectDataScopeService;
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
/** 站内信事件发布:创建项目后发「项目创建」通知,由 NotifySendEventListener 事务提交后统一发送。 */
|
||||
@Resource
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
/** 团队通知接收人解析:仅 active、排除创建者角色、逐成员带代表角色中文名。 */
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.framework.notify.TeamNotifyRecipientResolver teamNotifyRecipientResolver;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -167,6 +176,8 @@ class ProjectServiceImpl implements ProjectService {
|
||||
initDefaultRequirementModule(project);
|
||||
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
||||
buildProjectFieldChanges(null, project), null);
|
||||
// 团队成员已全部落库,发「项目创建」通知(操作人由监听器统一排除)
|
||||
publishProjectCreatedNotify(project);
|
||||
return project.getId();
|
||||
}
|
||||
|
||||
@@ -243,9 +254,37 @@ class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
// 9) 初始化项目需求的根模块
|
||||
initDefaultRequirementModule(project);
|
||||
// 10) 团队成员已全部落库,发「项目创建」通知(操作人由监听器统一排除)
|
||||
publishProjectCreatedNotify(project);
|
||||
return project.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目后逐成员发「项目创建」站内信:显式团队成员(排除创建者角色),每人带自己的角色名;
|
||||
* 操作人自己由监听器统一排除。仅 publish,发送由 NotifySendEventListener 事务提交后处理。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishProjectCreatedNotify(ProjectDO project) {
|
||||
Long operatorId = SecurityFrameworkUtils.getLoginUserId();
|
||||
List<com.njcn.rdms.module.project.framework.notify.TeamRecipient> recipients =
|
||||
teamNotifyRecipientResolver.resolveActiveExcludingCreator(
|
||||
ProjectObjectConstants.OBJECT_TYPE, project.getId(),
|
||||
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode(),
|
||||
ProjectObjectConstants.MANAGER_ROLE_CODE);
|
||||
for (com.njcn.rdms.module.project.framework.notify.TeamRecipient r : recipients) {
|
||||
// 逐成员一条事件:每人带自己的代表角色中文名
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("projectName", project.getProjectName());
|
||||
params.put("roleName", r.roleName());
|
||||
applicationEventPublisher.publishEvent(
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of(
|
||||
java.util.List.of(r.userId()),
|
||||
com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants.PROJECT_CREATED,
|
||||
params,
|
||||
com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants.NORMAL, operatorId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新接口专用:严格的方向解析。
|
||||
*
|
||||
@@ -903,12 +942,19 @@ class ProjectServiceImpl implements ProjectService {
|
||||
Map<Long, AdminUserRespDTO> userMap = userIds.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: adminUserApi.getUserMap(userIds);
|
||||
// 当前登录用户在这批项目上的角色(当前页两次查询,无 N+1;无角色项目返回空数组)
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
List<Long> objectIds = projects.stream()
|
||||
.map(ProjectDO::getId).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
Map<Long, List<CurrentUserRoleVO>> rolesByProject = currentUserRoleResolver.resolveByObjectIds(
|
||||
ProjectObjectConstants.OBJECT_TYPE, loginUserId, objectIds, ProjectObjectConstants.MANAGER_ROLE_CODE);
|
||||
return projects.stream().map(project -> {
|
||||
ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class);
|
||||
ProductDO product = project.getProductId() == null ? null : productMap.get(project.getProductId());
|
||||
respVO.setProductName(product == null ? null : product.getName());
|
||||
AdminUserRespDTO manager = project.getManagerUserId() == null ? null : userMap.get(project.getManagerUserId());
|
||||
respVO.setManagerUserNickname(manager == null ? null : manager.getNickname());
|
||||
respVO.setCurrentUserRoles(rolesByProject.getOrDefault(project.getId(), Collections.emptyList()));
|
||||
return respVO;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import com.njcn.rdms.module.project.util.DueRangeSupport;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
|
||||
@@ -52,8 +54,10 @@ import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -63,6 +67,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -123,6 +128,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
private ProjectRequirementMapper projectRequirementMapper;
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
/** 站内信事件发布:创建执行后发「执行指派」通知,由 NotifySendEventListener 事务提交后统一发送。 */
|
||||
@Resource
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
/**
|
||||
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
|
||||
* 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。
|
||||
@@ -167,9 +175,29 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null,
|
||||
initialStatusCode, buildExecutionFieldChanges(null, execution), null);
|
||||
createExecutionAssignees(execution.getId(), projectId, assigneeUserIds);
|
||||
publishExecutionAssignedNotify(execution);
|
||||
return execution.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建执行后发送「执行指派」站内信事件:接收人 = 负责人 + 活跃协办人(操作人自己由监听器统一排除)。
|
||||
* 仅 publish 事件,发送由 NotifySendEventListener 事务提交后处理;通知失败不影响执行创建。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishExecutionAssignedNotify(ProjectExecutionDO execution) {
|
||||
List<Long> recipients = new ArrayList<>();
|
||||
recipients.add(execution.getOwnerId());
|
||||
executionAssigneeMapper.selectActiveListByExecutionId(execution.getId())
|
||||
.forEach(a -> recipients.add(a.getUserId()));
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
ProjectDO project = projectMapper.selectById(execution.getProjectId());
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("executionName", execution.getExecutionName());
|
||||
applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
|
||||
NotifyTemplateCodeConstants.EXECUTION_ASSIGNED, params,
|
||||
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 项目任务 Service 接口。
|
||||
*/
|
||||
@@ -98,6 +100,18 @@ public interface ProjectTaskService {
|
||||
*/
|
||||
void internalAutoStartByWorklog(ProjectTaskDO task);
|
||||
|
||||
/**
|
||||
* 工作日志填报触发:当任务"实际开始时间"为空时,用给定的开始日期回填,使"任务何时开始"
|
||||
* 以最早一条工作日志的开始日期为准。owner / 协办人共用,不限任务当前状态。
|
||||
* <p>
|
||||
* 与 {@link #internalAutoStartByWorklog} 解耦:本方法只回填 actualStartDate(不动实际结束日期、
|
||||
* 不推进任务状态)。调用顺序应在 internalAutoStartByWorklog 之前,确保后者的回填钩子见到非空值即跳过,
|
||||
* 不会再用"提交当天"覆盖工作日志的开始日期。
|
||||
* <p>
|
||||
* 任务实际开始时间已有值、或入参为空时静默 return。
|
||||
*/
|
||||
void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate);
|
||||
|
||||
/**
|
||||
* 由执行 cancel 触发的级联:取消指定执行下所有未终态的顶层任务(parentTaskId IS NULL)。
|
||||
* 顶层任务自身的内部链路会再级联自己的子任务,整棵子树通过链式实现。
|
||||
|
||||
@@ -53,6 +53,7 @@ import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -195,7 +196,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务后发送「任务指派」站内信事件:接收人 = 负责人 + 活跃协办人(含操作人自己)。
|
||||
* 创建任务后发送「任务指派」站内信事件:接收人 = 负责人 + 活跃协办人(操作人自己由监听器统一排除)。
|
||||
* 仅 publish 事件,真正发送由 {@link com.njcn.rdms.module.project.framework.notify.NotifySendEventListener}
|
||||
* 在事务提交后处理;通知失败不影响任务创建。
|
||||
*/
|
||||
@@ -209,8 +210,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("taskName", task.getTaskTitle());
|
||||
applicationEventPublisher.publishEvent(
|
||||
NotifySendEvent.of(recipients, NotifyTemplateCodeConstants.TASK_ASSIGNED, params));
|
||||
applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
|
||||
NotifyTemplateCodeConstants.TASK_ASSIGNED, params,
|
||||
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -451,6 +453,21 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate) {
|
||||
if (taskId == null || startDate == null) {
|
||||
return;
|
||||
}
|
||||
ProjectTaskDO current = projectTaskMapper.selectById(taskId);
|
||||
// 仅在实际开始时间为空时回填;已有值(含 internalAutoStartByWorklog 已写过)则保持不变
|
||||
if (current == null || current.getActualStartDate() != null) {
|
||||
return;
|
||||
}
|
||||
// 只动开始日期:实际结束日期传 null,依据全局 FieldStrategy 不会被覆盖
|
||||
projectTaskMapper.updateActualDatesById(taskId, startDate, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) {
|
||||
validateExecutionExists(projectId, executionId);
|
||||
|
||||
@@ -118,7 +118,11 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
|
||||
TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
|
||||
taskWorklogMapper.insert(worklog);
|
||||
// 任意填报人(owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),并写入 actualStartDate。
|
||||
// 实际开始时间为空时,用本条工作日志的开始日期回填(owner / 协办人一致,不限任务当前状态)。
|
||||
// 必须在 internalAutoStartByWorklog 之前:先把开始日期落库,自动开始里的回填钩子见到非空值即跳过,
|
||||
// 不会再用"提交当天"覆盖工作日志填写的开始日期。
|
||||
projectTaskService.fillActualStartDateIfAbsent(taskId, reqVO.getStartDate());
|
||||
// 任意填报人(owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),推进任务状态。
|
||||
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
|
||||
projectTaskService.internalAutoStartByWorklog(task);
|
||||
// 仅 owner 填报触发任务进度同步:任务整体进度以 owner 本人最新一条工时为权威源,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#################### 注册中心 + 配置中心相关配置 ####################
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: 192.168.1.103:18848 # Nacos 服务器地址
|
||||
username: # Nacos 账号
|
||||
password: # Nacos 密码
|
||||
discovery: # 【配置中心】配置项
|
||||
namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
metadata:
|
||||
version: 1.0.0 # 服务实例的版本号,可用于灰度发布
|
||||
config: # 【注册中心】配置项
|
||||
namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
|
||||
#################### 数据库相关配置 ####################
|
||||
# 数据源配置项
|
||||
autoconfigure:
|
||||
exclude:
|
||||
datasource:
|
||||
druid: # Druid 【监控】相关的全局配置
|
||||
web-stat-filter:
|
||||
enabled: true
|
||||
stat-view-servlet:
|
||||
enabled: true
|
||||
allow: # 设置白名单,不填则允许所有访问
|
||||
url-pattern: /druid/*
|
||||
login-username: # 控制台管理用户名和密码
|
||||
login-password:
|
||||
filter:
|
||||
stat:
|
||||
enabled: true
|
||||
log-slow-sql: true # 慢 SQL 记录
|
||||
slow-sql-millis: 100
|
||||
merge-sql: true
|
||||
wall:
|
||||
config:
|
||||
multi-statement-allow: true
|
||||
dynamic: # 多数据源配置
|
||||
druid: # Druid 【连接池】相关的全局配置
|
||||
initial-size: 5 # 初始连接数
|
||||
min-idle: 10 # 最小连接池数量
|
||||
max-active: 20 # 最大连接池数量
|
||||
max-wait: 60000 # 配置获取连接等待超时的时间,单位:毫秒(1 分钟)
|
||||
time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒(1 分钟)
|
||||
min-evictable-idle-time-millis: 600000 # 配置一个连接在池中最小生存的时间,单位:毫秒(10 分钟)
|
||||
max-evictable-idle-time-millis: 1800000 # 配置一个连接在池中最大生存的时间,单位:毫秒(30 分钟)
|
||||
validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
pool-prepared-statements: true # 是否开启 PreparedStatement 缓存
|
||||
max-pool-prepared-statement-per-connection-size: 20 # 每个连接缓存的 PreparedStatement 数量
|
||||
primary: master
|
||||
datasource:
|
||||
master:
|
||||
url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
|
||||
username: root
|
||||
password: njcnpqs
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 192.168.1.22 # 地址
|
||||
port: 16379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
# password: njcnpqs # 密码,建议生产环境开启
|
||||
|
||||
|
||||
#################### 监控相关配置 ####################
|
||||
|
||||
# Actuator 监控端点的配置项
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator
|
||||
exposure:
|
||||
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 *,可以开放所有端点。
|
||||
|
||||
|
||||
# 日志文件配置
|
||||
logging:
|
||||
file:
|
||||
name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径
|
||||
|
||||
#################### RDMS 相关配置 ####################
|
||||
|
||||
# RDMS 配置项,设置当前项目所有自定义的配置
|
||||
rdms:
|
||||
demo: true # 开启演示模式
|
||||
@@ -1,98 +0,0 @@
|
||||
#################### 注册中心 + 配置中心相关配置 ####################
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: 192.168.1.103:18848 # Nacos 服务器地址
|
||||
username: # Nacos 账号
|
||||
password: # Nacos 密码
|
||||
discovery: # 【配置中心】配置项
|
||||
namespace: 0cd9c1b2-56ba-4e1d-a23b-f951392c46bf # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
metadata:
|
||||
version: 1.0.0 # 服务实例的版本号,可用于灰度发布
|
||||
config: # 【注册中心】配置项
|
||||
namespace: 0cd9c1b2-56ba-4e1d-a23b-f951392c46bf # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
|
||||
#################### 数据库相关配置 ####################
|
||||
# 数据源配置项
|
||||
autoconfigure:
|
||||
exclude:
|
||||
datasource:
|
||||
druid: # Druid 【监控】相关的全局配置
|
||||
web-stat-filter:
|
||||
enabled: true
|
||||
stat-view-servlet:
|
||||
enabled: true
|
||||
allow: # 设置白名单,不填则允许所有访问
|
||||
url-pattern: /druid/*
|
||||
login-username: # 控制台管理用户名和密码
|
||||
login-password:
|
||||
filter:
|
||||
stat:
|
||||
enabled: true
|
||||
log-slow-sql: true # 慢 SQL 记录
|
||||
slow-sql-millis: 100
|
||||
merge-sql: true
|
||||
wall:
|
||||
config:
|
||||
multi-statement-allow: true
|
||||
dynamic: # 多数据源配置
|
||||
druid: # Druid 【连接池】相关的全局配置
|
||||
initial-size: 5 # 初始连接数
|
||||
min-idle: 10 # 最小连接池数量
|
||||
max-active: 20 # 最大连接池数量
|
||||
max-wait: 60000 # 配置获取连接等待超时的时间,单位:毫秒(1 分钟)
|
||||
time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒(1 分钟)
|
||||
min-evictable-idle-time-millis: 600000 # 配置一个连接在池中最小生存的时间,单位:毫秒(10 分钟)
|
||||
max-evictable-idle-time-millis: 1800000 # 配置一个连接在池中最大生存的时间,单位:毫秒(30 分钟)
|
||||
validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
pool-prepared-statements: true # 是否开启 PreparedStatement 缓存
|
||||
max-pool-prepared-statement-per-connection-size: 20 # 每个连接缓存的 PreparedStatement 数量
|
||||
primary: master
|
||||
datasource:
|
||||
master:
|
||||
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
|
||||
username: root
|
||||
password: njcnpqs
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 6379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
# password: njcnpqs # 密码,建议生产环境开启
|
||||
|
||||
|
||||
#################### 监控相关配置 ####################
|
||||
|
||||
# Actuator 监控端点的配置项
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator
|
||||
exposure:
|
||||
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 *,可以开放所有端点。
|
||||
|
||||
|
||||
# 日志文件配置
|
||||
logging:
|
||||
level:
|
||||
# 配置本模块 MyBatis Mapper 打印日志
|
||||
com.njcn.rdms.module.project.dal.mysql: debug
|
||||
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
|
||||
|
||||
# RDMS 配置项,设置当前项目所有自定义的本地扩展配置
|
||||
rdms:
|
||||
env: # 多环境的配置项
|
||||
tag: ${HOSTNAME}
|
||||
captcha:
|
||||
enable: false
|
||||
security:
|
||||
mock-enable: true
|
||||
access-log: # 访问日志的配置项
|
||||
enable: true
|
||||
@@ -1,15 +1,27 @@
|
||||
spring:
|
||||
application:
|
||||
name: rdms-project-server
|
||||
profiles:
|
||||
active: local
|
||||
main:
|
||||
allow-circular-references: true # 允许循环依赖,因为项目当前沿用三层架构组织方式。
|
||||
allow-bean-definition-overriding: true # 允许 Bean 覆盖,例如 Feign 等会存在重复定义的服务
|
||||
cloud:
|
||||
# 注册中心 + 配置中心连接(值由根 pom 的 nacos.* 属性在打包时注入)
|
||||
nacos:
|
||||
server-addr: @nacos.server-addr@
|
||||
username: @nacos.username@
|
||||
password: @nacos.password@
|
||||
discovery:
|
||||
namespace: @nacos.namespace@
|
||||
group: @nacos.group@
|
||||
metadata:
|
||||
version: ${rdms.info.version} # 灰度发布用的实例版本号
|
||||
config:
|
||||
namespace: @nacos.namespace@
|
||||
group: @nacos.group@
|
||||
config:
|
||||
import:
|
||||
- optional:classpath:application-${spring.profiles.active}.yaml # 加载【本地】配置
|
||||
- optional:nacos:${spring.application.name}-${spring.profiles.active}.yaml # 加载【Nacos】的配置
|
||||
- nacos:rdms-common.yaml # 公共非敏感配置(数据库地址/Redis/开关等)
|
||||
- nacos:rdms-common-secret.yaml # 公共敏感配置(数据库账密/加解密秘钥)
|
||||
# Servlet 配置
|
||||
servlet:
|
||||
# 文件上传相关配置项
|
||||
@@ -48,6 +60,8 @@ server:
|
||||
logging:
|
||||
file:
|
||||
name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径
|
||||
level:
|
||||
com.njcn.rdms.module.project.dal.mysql: debug # 打印本模块 Mapper 的 SQL 日志
|
||||
|
||||
--- #################### 接口文档配置 ####################
|
||||
springdoc:
|
||||
@@ -75,8 +89,7 @@ mybatis-plus:
|
||||
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
|
||||
banner: false # 关闭控制台的 Banner 打印
|
||||
type-aliases-package: ${rdms.info.base-package}.dal.dataobject
|
||||
encryptor:
|
||||
password: cDHvwsYb9eyLNBHp # 加解密秘钥,生产环境务必通过 Nacos 注入,切勿硬编码
|
||||
# encryptor.password(@EncryptField 字段加解密秘钥)已外置到 Nacos rdms-common-secret.yaml,不再硬编码进 git
|
||||
|
||||
mybatis-plus-join:
|
||||
banner: false # 关闭控制台的 Banner 打印
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 临期/逾期告警发送记录表(去重凭证)
|
||||
-- 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html
|
||||
-- 临期档:同(object_type,object_id,planned_end_date快照)只发一次,改期=新快照=重新告警
|
||||
-- 逾期档:按(object_type,object_id,alert_date)每天一条
|
||||
CREATE TABLE IF NOT EXISTS rdms_due_alert_record (
|
||||
id BIGINT NOT NULL COMMENT '主键(Java 侧 MyBatis-Plus 雪花生成,无 AUTO_INCREMENT)',
|
||||
object_type VARCHAR(32) NOT NULL COMMENT '告警域对象类型:project/project_requirement/product_requirement/execution/task/personal_item',
|
||||
object_id BIGINT NOT NULL COMMENT '对象主键',
|
||||
alert_type VARCHAR(16) NOT NULL COMMENT '告警档:approaching=临期/overdue=逾期',
|
||||
planned_end_date DATE NOT NULL COMMENT '判定时计划完成日快照(临期档去重键)',
|
||||
alert_date DATE NOT NULL COMMENT '告警发送日(逾期档每天一条的去重键)',
|
||||
creator VARCHAR(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater VARCHAR(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_due_alert (object_type, object_id, alert_type, planned_end_date, alert_date)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '临期/逾期告警发送记录(去重凭证,只增不删)';
|
||||
@@ -1,24 +0,0 @@
|
||||
-- TD-015 项目完成校验:按 project_id 数非终态子对象需要的索引
|
||||
-- MySQL 无 CREATE INDEX IF NOT EXISTS,用 information_schema 守卫实现幂等(可重复执行);与演示库补丁 docs/sql/patches/2026-06-05-TD015-完成校验-01.sql 写法一致
|
||||
|
||||
-- 需求表:原 idx_rdms_project_requirement_project_status_deleted 实际列为 (status_code, deleted),缺 project_id 前导,补齐
|
||||
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'rdms_project_requirement'
|
||||
AND INDEX_NAME = 'idx_rdms_project_requirement_proj_status');
|
||||
SET @sql := IF(@idx = 0,
|
||||
'CREATE INDEX idx_rdms_project_requirement_proj_status ON rdms_project_requirement (project_id, status_code, deleted)',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 任务表(rdms_task):与执行表 idx_rdms_pe_proj_status 对齐,让按项目数非终态任务走覆盖前缀
|
||||
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'rdms_task'
|
||||
AND INDEX_NAME = 'idx_rdms_task_proj_status');
|
||||
SET @sql := IF(@idx = 0,
|
||||
'CREATE INDEX idx_rdms_task_proj_status ON rdms_task (project_id, status_code, deleted)',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 执行表 rdms_project_execution 已有 idx_rdms_pe_proj_status(project_id, status_code, deleted),无需新增
|
||||
@@ -50,4 +50,17 @@ class NotifySendEventListenerTest extends BaseMockitoUnitTest {
|
||||
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 2L), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_excludesOperator() {
|
||||
// userIds 含操作人 1L 与 2L;operatorUserId=1L
|
||||
listener.onNotifySend(NotifySendEvent.of(
|
||||
Arrays.asList(1L, 2L), "task_assigned", new HashMap<>(),
|
||||
NotifyMessageLevelConstants.NORMAL, 1L));
|
||||
// 1L 是操作人,应被排除;仅 2L 收到,且五参工厂的 level 应原样透传
|
||||
verify(notifyMessageSendApi, times(0))
|
||||
.sendSingleNotifyToAdmin(eq(1L), any(), any(), any());
|
||||
verify(notifyMessageSendApi, times(1))
|
||||
.sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TeamNotifyRecipientResolverTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private TeamNotifyRecipientResolver resolver;
|
||||
@Mock
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Mock
|
||||
private ObjectPermissionApi objectPermissionApi;
|
||||
|
||||
@Test
|
||||
void resolve_filtersInactiveAndCreator_picksManagerAsPrimary() {
|
||||
// u1: manager(active) + creator(active) → 保留,代表角色=manager
|
||||
// u2: creator only(active) → 排除(纯创建者)
|
||||
// u3: watcher(inactive) → 排除(失效)
|
||||
UserObjectRoleDO r1 = row(1L, 100L, 0); // manager role id 100
|
||||
UserObjectRoleDO r1c = row(1L, 300L, 0); // creator role id 300
|
||||
UserObjectRoleDO r2 = row(2L, 300L, 0); // creator only
|
||||
UserObjectRoleDO r3 = row(3L, 200L, 1); // watcher inactive
|
||||
when(userObjectRoleMapper.selectListByObject("project", 10L))
|
||||
.thenReturn(List.of(r1, r1c, r2, r3));
|
||||
when(objectPermissionApi.getObjectRoleList(anySet(), eq(ObjectRoleConstants.ROLE_SCOPE_OBJECT), eq("project")))
|
||||
.thenReturn(CommonResult.success(List.of(
|
||||
role(100L, "project_manager", "项目经理"),
|
||||
role(200L, "project_watcher", "项目观察者"),
|
||||
role(300L, "project_creator", "项目创建者"))));
|
||||
|
||||
List<TeamRecipient> out = resolver.resolveActiveExcludingCreator(
|
||||
"project", 10L, "project_creator", "project_manager");
|
||||
|
||||
assertEquals(1, out.size());
|
||||
assertEquals(1L, out.get(0).userId());
|
||||
assertEquals("项目经理", out.get(0).roleName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_noActiveMembers_returnsEmpty() {
|
||||
// 全部失效 → 直接空集,不触达 RPC
|
||||
UserObjectRoleDO r1 = row(1L, 100L, 1);
|
||||
UserObjectRoleDO r2 = row(2L, 200L, 1);
|
||||
when(userObjectRoleMapper.selectListByObject("product", 20L))
|
||||
.thenReturn(List.of(r1, r2));
|
||||
|
||||
List<TeamRecipient> out = resolver.resolveActiveExcludingCreator(
|
||||
"product", 20L, "product_creator", "product_manager");
|
||||
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_mixedActiveInactiveSameUser_inactiveRowIgnored() {
|
||||
// u1 同时持有 manager(active) 与 watcher(inactive):失效行既不应踢掉 u1,也不应成为代表角色
|
||||
UserObjectRoleDO r1 = row(1L, 100L, 0); // manager active
|
||||
UserObjectRoleDO r1w = row(1L, 200L, 1); // watcher inactive
|
||||
when(userObjectRoleMapper.selectListByObject("project", 10L))
|
||||
.thenReturn(List.of(r1, r1w));
|
||||
when(objectPermissionApi.getObjectRoleList(anySet(), eq(ObjectRoleConstants.ROLE_SCOPE_OBJECT), eq("project")))
|
||||
.thenReturn(CommonResult.success(List.of(
|
||||
role(100L, "project_manager", "项目经理"),
|
||||
role(200L, "project_watcher", "项目观察者"))));
|
||||
|
||||
List<TeamRecipient> out = resolver.resolveActiveExcludingCreator(
|
||||
"project", 10L, "project_creator", "project_manager");
|
||||
|
||||
assertEquals(1, out.size());
|
||||
assertEquals(1L, out.get(0).userId());
|
||||
assertEquals("项目经理", out.get(0).roleName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_nonManagerUser_fallsBackToLowestRoleId() {
|
||||
// u1 持两个 active 非经理非创建者角色(200 与 400):代表角色按 roleId 升序取 200
|
||||
UserObjectRoleDO r1a = row(1L, 400L, 0);
|
||||
UserObjectRoleDO r1b = row(1L, 200L, 0);
|
||||
when(userObjectRoleMapper.selectListByObject("project", 10L))
|
||||
.thenReturn(List.of(r1a, r1b));
|
||||
when(objectPermissionApi.getObjectRoleList(anySet(), eq(ObjectRoleConstants.ROLE_SCOPE_OBJECT), eq("project")))
|
||||
.thenReturn(CommonResult.success(List.of(
|
||||
role(200L, "project_watcher", "项目观察者"),
|
||||
role(400L, "project_member", "项目成员"))));
|
||||
|
||||
List<TeamRecipient> out = resolver.resolveActiveExcludingCreator(
|
||||
"project", 10L, "project_creator", "project_manager");
|
||||
|
||||
assertEquals(1, out.size());
|
||||
assertEquals(1L, out.get(0).userId());
|
||||
assertEquals("项目观察者", out.get(0).roleName());
|
||||
}
|
||||
|
||||
private UserObjectRoleDO row(Long userId, Long roleId, Integer status) {
|
||||
UserObjectRoleDO row = new UserObjectRoleDO();
|
||||
row.setUserId(userId);
|
||||
row.setRoleId(roleId);
|
||||
row.setStatus(status);
|
||||
return row;
|
||||
}
|
||||
|
||||
private ObjectRoleRespDTO role(Long id, String code, String name) {
|
||||
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
|
||||
role.setId(id);
|
||||
role.setCode(code);
|
||||
role.setName(name);
|
||||
return role;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,6 +53,57 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
|
||||
private ObjectStatusModelMapper statusModelMapper;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.dal.mysql.product.ProductMapper productMapper;
|
||||
@Mock
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
// ========== 创建需求通知处理人测试(Task 9) ==========
|
||||
|
||||
/**
|
||||
* Task 9:创建产品需求后通知处理人(currentHandlerUserId),操作人由监听器统一排除。
|
||||
*/
|
||||
@Test
|
||||
void createRequirement_notifiesHandler() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L);
|
||||
ProductRequirementDO req = new ProductRequirementDO();
|
||||
req.setId(1L);
|
||||
req.setProductId(20L);
|
||||
req.setTitle("电池告警");
|
||||
req.setCurrentHandlerUserId(5L);
|
||||
com.njcn.rdms.module.project.dal.dataobject.product.ProductDO product =
|
||||
new com.njcn.rdms.module.project.dal.dataobject.product.ProductDO();
|
||||
product.setName("智能终端");
|
||||
when(productMapper.selectById(20L)).thenReturn(product);
|
||||
|
||||
ArgumentCaptor<com.njcn.rdms.module.project.framework.notify.NotifySendEvent> captor =
|
||||
ArgumentCaptor.forClass(com.njcn.rdms.module.project.framework.notify.NotifySendEvent.class);
|
||||
requirementService.publishRequirementAssignedNotify(req);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent ev = captor.getValue();
|
||||
assertEquals("product_requirement_assigned", ev.getTemplateCode());
|
||||
assertEquals(9L, ev.getOperatorUserId());
|
||||
assertTrue(ev.getUserIds().contains(5L));
|
||||
assertEquals("电池告警", ev.getParams().get("requirementTitle"));
|
||||
assertEquals("智能终端", ev.getParams().get("productName"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 9:处理人为空时不发通知。
|
||||
*/
|
||||
@Test
|
||||
void createRequirement_whenHandlerNull_shouldNotPublish() {
|
||||
ProductRequirementDO req = new ProductRequirementDO();
|
||||
req.setId(1L);
|
||||
req.setProductId(20L);
|
||||
req.setTitle("电池告警");
|
||||
req.setCurrentHandlerUserId(null);
|
||||
|
||||
requirementService.publishRequirementAssignedNotify(req);
|
||||
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
// ========== 创建需求测试 ==========
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductC
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||
@@ -51,7 +52,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -83,9 +87,15 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.member.CurrentUserRoleResolver currentUserRoleResolver;
|
||||
@Mock
|
||||
private ObjectDataScopeService objectDataScopeService;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.framework.notify.TeamNotifyRecipientResolver teamNotifyRecipientResolver;
|
||||
@Mock
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Test
|
||||
void createProduct_shouldCreateDefaultRequirementModule() {
|
||||
@@ -572,7 +582,7 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());
|
||||
|
||||
PageResult<ProductDO> result = productService.getProductPage(new ProductPageReqVO());
|
||||
PageResult<ProductRespVO> result = productService.getProductPage(new ProductPageReqVO());
|
||||
|
||||
assertThat(result.getList()).isEmpty();
|
||||
verify(productMapper, never()).selectPage(any(ProductPageReqVO.class), any());
|
||||
@@ -606,6 +616,31 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishProductCreatedNotify_perMemberWithRole() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L);
|
||||
ProductDO product = new ProductDO();
|
||||
product.setId(20L);
|
||||
product.setName("智能终端");
|
||||
when(teamNotifyRecipientResolver.resolveActiveExcludingCreator(
|
||||
eq(com.njcn.rdms.module.project.constant.ProductObjectConstants.OBJECT_TYPE), eq(20L),
|
||||
anyString(), anyString()))
|
||||
.thenReturn(List.of(new com.njcn.rdms.module.project.framework.notify.TeamRecipient(5L, "产品经理")));
|
||||
|
||||
ArgumentCaptor<com.njcn.rdms.module.project.framework.notify.NotifySendEvent> captor =
|
||||
ArgumentCaptor.forClass(com.njcn.rdms.module.project.framework.notify.NotifySendEvent.class);
|
||||
productService.publishProductCreatedNotify(product);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent ev = captor.getValue();
|
||||
assertEquals("product_created", ev.getTemplateCode());
|
||||
assertEquals(9L, ev.getOperatorUserId());
|
||||
assertEquals("智能终端", ev.getParams().get("productName"));
|
||||
assertEquals("产品经理", ev.getParams().get("roleName"));
|
||||
assertTrue(ev.getUserIds().contains(5L));
|
||||
}
|
||||
}
|
||||
|
||||
private ProductDO createProduct(Long id, String directionCode, String name, Long managerUserId,
|
||||
String description, String statusCode) {
|
||||
ProductDO product = new ProductDO();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.njcn.rdms.module.project.service.project;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementStatusLogMapper;
|
||||
@@ -15,9 +18,13 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
@@ -27,7 +34,11 @@ import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
@@ -60,6 +71,10 @@ class ProjectRequirementServiceImplTest extends BaseMockitoUnitTest {
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||
@Mock
|
||||
private ProjectMapper projectMapper;
|
||||
@Mock
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Test
|
||||
void validateUsableForExecution_whenRequirementIdIsNull_shouldDoNothing() {
|
||||
@@ -121,6 +136,49 @@ class ProjectRequirementServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(ErrorCodeConstants.PROJECT_REQUIREMENT_HAS_EXECUTIONS_NOT_ALLOW_DELETE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 8:创建项目需求后通知处理人(currentHandlerUserId),操作人由监听器统一排除。
|
||||
*/
|
||||
@Test
|
||||
void createRequirement_notifiesHandler() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L);
|
||||
ProjectRequirementDO req = new ProjectRequirementDO();
|
||||
req.setId(1L);
|
||||
req.setProjectId(10L);
|
||||
req.setTitle("登录改造");
|
||||
req.setCurrentHandlerUserId(5L);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setProjectName("灿能项目");
|
||||
when(projectMapper.selectById(10L)).thenReturn(project);
|
||||
|
||||
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||
projectRequirementService.publishRequirementAssignedNotify(req);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
NotifySendEvent ev = captor.getValue();
|
||||
assertEquals("project_requirement_assigned", ev.getTemplateCode());
|
||||
assertEquals(9L, ev.getOperatorUserId());
|
||||
assertTrue(ev.getUserIds().contains(5L));
|
||||
assertEquals("登录改造", ev.getParams().get("requirementTitle"));
|
||||
assertEquals("灿能项目", ev.getParams().get("projectName"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 8:处理人为空时不发通知。
|
||||
*/
|
||||
@Test
|
||||
void createRequirement_whenHandlerNull_shouldNotPublish() {
|
||||
ProjectRequirementDO req = new ProjectRequirementDO();
|
||||
req.setId(1L);
|
||||
req.setProjectId(10L);
|
||||
req.setTitle("登录改造");
|
||||
req.setCurrentHandlerUserId(null);
|
||||
|
||||
projectRequirementService.publishRequirementAssignedNotify(req);
|
||||
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
private ProjectRequirementDO buildRequirement(Long id, Long projectId, String statusCode) {
|
||||
ProjectRequirementDO requirement = new ProjectRequirementDO();
|
||||
requirement.setId(id);
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectP
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
@@ -60,6 +61,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -88,6 +90,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.member.CurrentUserRoleResolver currentUserRoleResolver;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper projectRequirementModuleMapper;
|
||||
@Mock
|
||||
private ProjectStatusViewService projectStatusViewService;
|
||||
@@ -107,6 +111,10 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.framework.notify.TeamNotifyRecipientResolver teamNotifyRecipientResolver;
|
||||
@Mock
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Test
|
||||
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
|
||||
@@ -782,6 +790,33 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
.updateStatusByIdAndStatus(eq(projectId), eq("active"), eq("completed"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishProjectCreatedNotify_perMemberWithRole() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(10L);
|
||||
project.setProjectName("灿能项目");
|
||||
when(teamNotifyRecipientResolver.resolveActiveExcludingCreator(
|
||||
eq(ProjectObjectConstants.OBJECT_TYPE), eq(10L), anyString(), anyString()))
|
||||
.thenReturn(List.of(
|
||||
new com.njcn.rdms.module.project.framework.notify.TeamRecipient(5L, "项目经理"),
|
||||
new com.njcn.rdms.module.project.framework.notify.TeamRecipient(6L, "项目观察者")));
|
||||
|
||||
ArgumentCaptor<com.njcn.rdms.module.project.framework.notify.NotifySendEvent> captor =
|
||||
ArgumentCaptor.forClass(com.njcn.rdms.module.project.framework.notify.NotifySendEvent.class);
|
||||
projectService.publishProjectCreatedNotify(project);
|
||||
verify(applicationEventPublisher, times(2)).publishEvent(captor.capture());
|
||||
// 逐成员一条事件、各带自己的角色名
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent first = captor.getAllValues().get(0);
|
||||
assertEquals("project_created", first.getTemplateCode());
|
||||
assertEquals(9L, first.getOperatorUserId());
|
||||
assertEquals("项目经理", first.getParams().get("roleName"));
|
||||
assertEquals("灿能项目", first.getParams().get("projectName"));
|
||||
assertTrue(first.getUserIds().contains(5L));
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectSaveReqVO createReqVO(String code, Long productId, String projectName, Long managerUserId) {
|
||||
ProjectSaveReqVO reqVO = new ProjectSaveReqVO();
|
||||
reqVO.setProjectCode(code);
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
@@ -57,6 +58,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
@@ -105,6 +107,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
private ProjectRequirementMapper projectRequirementMapper;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||
@Mock
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
/**
|
||||
* 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true,既有测试不因 priority 校验失败。
|
||||
@@ -888,6 +892,38 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(true, auditCaptor.getValue().getFieldChanges().contains("priority"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建执行后应 publish「执行指派」站内信事件:接收人 = 负责人 + 活跃协办人,
|
||||
* 模板码 execution_assigned,operatorUserId = 当前登录用户(监听器统一排除),params 含项目名 / 执行名。
|
||||
*/
|
||||
@Test
|
||||
void createExecution_publishesAssignedNotify() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L);
|
||||
ProjectExecutionDO exec = new ProjectExecutionDO();
|
||||
exec.setId(1L);
|
||||
exec.setProjectId(10L);
|
||||
exec.setExecutionName("一阶段");
|
||||
exec.setOwnerId(5L);
|
||||
ExecutionAssigneeDO a = new ExecutionAssigneeDO();
|
||||
a.setUserId(6L);
|
||||
when(executionAssigneeMapper.selectActiveListByExecutionId(1L)).thenReturn(List.of(a));
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setProjectName("灿能项目");
|
||||
when(projectMapper.selectById(10L)).thenReturn(project);
|
||||
|
||||
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||
projectExecutionService.publishExecutionAssignedNotify(exec);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
NotifySendEvent ev = captor.getValue();
|
||||
assertEquals("execution_assigned", ev.getTemplateCode());
|
||||
assertEquals(7L, ev.getOperatorUserId());
|
||||
assertTrue(ev.getUserIds().containsAll(List.of(5L, 6L)));
|
||||
assertEquals("灿能项目", ev.getParams().get("projectName"));
|
||||
assertEquals("一阶段", ev.getParams().get("executionName"));
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectDO createEditableProject(Long projectId) {
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(projectId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
@@ -12,12 +13,16 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -65,4 +70,24 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals("演示项目", event.getParams().get("projectName"));
|
||||
assertEquals("联调任务", event.getParams().get("taskName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件须携带操作人(登录用户)id,供监听器统一从收件人里排除操作人自己。
|
||||
*/
|
||||
@Test
|
||||
void publishTaskAssignedNotify_carriesOperator() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(99L);
|
||||
ProjectTaskDO task = new ProjectTaskDO();
|
||||
task.setId(1L);
|
||||
task.setTaskTitle("接口联调");
|
||||
when(taskAssigneeMapper.selectActiveListByTaskId(1L)).thenReturn(List.of());
|
||||
when(projectMapper.selectById(any())).thenReturn(null);
|
||||
|
||||
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||
projectTaskService.publishTaskAssignedNotify(10L, task, 5L);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
assertEquals(99L, captor.getValue().getOperatorUserId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user