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:
2026-06-17 21:01:11 +08:00
parent a244ae4ad3
commit 117dd91afd
44 changed files with 998 additions and 528 deletions

48
pom.xml
View File

@@ -30,6 +30,16 @@
<mapstruct.version>1.6.3</mapstruct.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<swagger.version>2.2.38</swagger.version>
<maven-resources-plugin.version>3.3.1</maven-resources-plugin.version>
<!-- Nacos 连接信息(注册中心 + 配置中心公共项)。
各模块 application.yaml 用 @nacos.xxx@ 占位符引用,打包时由资源过滤注入;
单点维护,运行期仍可由环境变量(如 SPRING_CLOUD_NACOS_SERVER_ADDR覆盖。 -->
<nacos.server-addr>192.168.1.103:18848</nacos.server-addr>
<nacos.namespace>1924bcfb-4eab-4c58-9003-4a37d5fc2949</nacos.namespace>
<nacos.group>DEFAULT_GROUP</nacos.group>
<nacos.username></nacos.username>
<nacos.password></nacos.password>
</properties>
<dependencyManagement>
@@ -133,8 +143,46 @@
</dependencyManagement>
<build>
<!-- 资源过滤:仅对 application*/bootstrap* 配置文件开启过滤(注入 @nacos.xxx@ 等 pom 属性),
其余资源(验证码底图等二进制)一律不过滤,避免被破坏。 -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>application*.yml</include>
<include>application*.yaml</include>
<include>bootstrap*.yml</include>
<include>bootstrap*.yaml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>application*.yml</exclude>
<exclude>application*.yaml</exclude>
<exclude>bootstrap*.yml</exclude>
<exclude>bootstrap*.yaml</exclude>
</excludes>
</resource>
</resources>
<pluginManagement>
<plugins>
<!-- maven-resources-plugin 插件,配置 @ 为唯一占位符分隔符,
关闭默认 ${} 分隔符,避免 Maven 误解析 Spring 自身的 ${...} 占位符。 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven-resources-plugin.version}</version>
<configuration>
<useDefaultDelimiters>false</useDefaultDelimiters>
<delimiters>
<delimiter>@</delimiter>
</delimiters>
</configuration>
</plugin>
<!-- maven-surefire-plugin 插件,用于运行单元测试。 -->
<!-- 注意,需要使用 3.0.X+,因为要支持 Junit 5 版本 -->
<plugin>

View File

@@ -54,12 +54,6 @@
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Config 配置中心相关 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 工具类相关 -->
<dependency>
<groupId>com.google.guava</groupId>

View File

@@ -1,23 +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
config: # 【注册中心】配置项
namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
#################### 监控相关配置 ####################
# Actuator 监控端点的配置项
management:
endpoints:
web:
base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator
exposure:
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。

View File

@@ -1,29 +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
config: # 【注册中心】配置项
namespace: 0cd9c1b2-56ba-4e1d-a23b-f951392c46bf # 命名空间。这里使用 dev 开发环境
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
#################### 监控相关配置 ####################
# Actuator 监控端点的配置项
management:
endpoints:
web:
base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator
exposure:
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。
# 日志文件配置
logging:
level:
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR

View File

@@ -2,9 +2,6 @@ spring:
application:
name: gateway-server
profiles:
active: local
http:
codecs:
max-in-memory-size: 10MB # 调整缓冲区大小https://gitee.com/zhijiantianya/rdms-cloud/pulls/176
@@ -20,12 +17,15 @@ spring:
main:
allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。
config:
import:
- optional:classpath:application-${spring.profiles.active}.yaml # 加载【本地】配置
- optional:nacos:${spring.application.name}-${spring.profiles.active}.yaml # 加载【Nacos】的配置
cloud:
# 注册中心连接(值由根 pom 的 nacos.* 属性在打包时注入)。网关仅用 Nacos 做服务发现,不加载配置中心文件。
nacos:
server-addr: @nacos.server-addr@
username: @nacos.username@
password: @nacos.password@
discovery:
namespace: @nacos.namespace@
group: @nacos.group@
# Spring Cloud Gateway 配置项,对应 GatewayProperties 类
gateway:
server:
@@ -89,6 +89,16 @@ server:
logging:
file:
name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径
level:
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
# Actuator 监控端点的配置项
management:
endpoints:
web:
base-path: /actuator # Actuator 提供的 API 接口的根目录
exposure:
include: '*' # 开放所有端点
knife4j:
# 聚合 Swagger 文档,参考 https://doc.xiaominfo.com/docs/action/springcloud-gateway 文档

View File

@@ -113,7 +113,9 @@
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
<configuration>
<addResources>true</addResources>
<!-- 必须为 falseaddResources=true 会让 spring-boot:run 直接挂载源 resources 目录、
跳过资源过滤,导致 application.yaml 里的 @nacos.xxx@ 占位符不被替换。 -->
<addResources>false</addResources>
</configuration>
<executions>
<execution>

View File

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

View File

@@ -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 组装(含当前用户角色聚合)已下沉到 ServiceController 直接返回
return success(productService.getProductPage(pageReqVO));
}
@GetMapping("/overview-summary")

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,10 @@ public class NotifySendEventListener {
targets.add(userId);
}
}
// 排除操作人创建通知不发给操作人自己operatorUserId 为 null 的存量场景不排除)
if (event.getOperatorUserId() != null) {
targets.remove(event.getOperatorUserId());
}
if (targets.isEmpty()) {
return;
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
package com.njcn.rdms.module.project.framework.notify;
/** 团队通知接收人:用户编号 + 代表角色中文名 */
public record TeamRecipient(Long userId, String roleName) {}

View File

@@ -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 -&gt; 角色列表(稳定顺序:经理优先,其余按 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));
}
}

View File

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

View File

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

View File

@@ -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);
/**
* 获取产品入口页概览统计

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
* 顶层任务自身的内部链路会再级联自己的子任务,整棵子树通过链式实现。

View File

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

View File

@@ -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 本人最新一条工时为权威源,

View File

@@ -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 # 开启演示模式

View File

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

View File

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

View File

@@ -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 = '临期/逾期告警发送记录(去重凭证,只增不删)';

View File

@@ -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),无需新增

View File

@@ -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 与 2LoperatorUserId=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));
}
}

View File

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

View File

@@ -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());
}
// ========== 创建需求测试 ==========

View File

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

View File

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

View File

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

View File

@@ -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_assignedoperatorUserId = 当前登录用户监听器统一排除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);

View File

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

View File

@@ -140,7 +140,9 @@
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
<configuration>
<addResources>true</addResources> <!-- 开启热部署必须配置 -->
<!-- 必须为 falseaddResources=true 会让 spring-boot:run 直接挂载源 resources 目录、
跳过资源过滤,导致 application.yaml 里的 @nacos.xxx@ 占位符不被替换。 -->
<addResources>false</addResources>
</configuration>
<executions>
<execution>

View File

@@ -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:
demo: true # 开启演示模式

View File

@@ -1,100 +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.system.dal.mysql: debug
com.njcn.rdms.module.system.dal.mysql.logger.ApiErrorLogMapper: INFO # 配置 ApiErrorLogMapper 的日志级别为 info避免和 GlobalExceptionHandler 重复打印
com.njcn.rdms.module.system.dal.mysql.file.FileConfigMapper: INFO # 配置 FileConfigMapper 的日志级别为 info
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
# 灿能配置项,设置当前项目所有自定义的配置
rdms:
env: # 多环境的配置项
tag: ${HOSTNAME}
captcha:
enable: false
security:
mock-enable: true
access-log: # 访问日志的配置项
enable: true

View File

@@ -1,15 +1,28 @@
spring:
application:
name: rdms-system-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 # 公共敏感配置(数据库账密/加解密秘钥)
- nacos:rdms-system-server-secret.yaml # system 独有敏感配置RSA 私钥)
# Servlet 配置
servlet:
# 文件上传相关配置项
@@ -47,6 +60,10 @@ server:
logging:
file:
name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径
level:
com.njcn.rdms.module.system.dal.mysql: debug # 打印本模块 Mapper 的 SQL 日志
com.njcn.rdms.module.system.dal.mysql.logger.ApiErrorLogMapper: INFO # 避免和 GlobalExceptionHandler 重复打印
com.njcn.rdms.module.system.dal.mysql.file.FileConfigMapper: INFO
--- #################### 接口文档配置 ####################
springdoc:
@@ -76,8 +93,7 @@ mybatis-plus:
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
banner: false # 关闭控制台的 Banner 打印
type-aliases-package: ${rdms.info.base-package}.dal.dataobject
encryptor:
password: cDHvwsYb9eyLNBHp # 加解密的秘钥,可使用 https://www.imaegoo.com/2020/aes-key-generator/ 网站生成。数据库存密文,业务代码透明拿到明文(@EncryptField 注解字段自动加解密)。秘钥一旦变更,历史密文将无法解密,生产环境务必通过 Nacos 注入,切勿硬编码。
# encryptor.password@EncryptField 字段加解密秘钥)已外置到 Nacos rdms-common-secret.yaml不再硬编码进 git
mybatis-plus-join:
banner: false # 关闭控制台的 Banner 打印
@@ -131,6 +147,6 @@ rdms:
enable: true # 启用密码相关接口的请求解密能力
header: X-Api-Encrypt # 请求加密标记头
algorithm: RSA # 密码相关接口的请求体采用 RSA 非对称加密
request-key: 'MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC/aShtWjlpINa+ZZkgp4sbt2jA4tPCN1YjDLv5SZMHDd7q8lbkE0SOudbuSKp5P3tVCPZXowyZom5+l56AAIYCaG5OcbzeRUtB6JcvmuU9SZ008zw7z2BIzeIzMtJSGf6u8BocVeMo27bGyyh1ifUXbpKVU7V7DBLzYADAQ9Jqi0vsqrxDGDu+Zm3LpFwSOnv85pgC0d+9re57CIYynXVmTLAo+V5DedPsceNCAByRs1kUyFMwyoPNbmgjcpKbewD6laxR9GtnFR/bCzfnz8Up7ANtuHCPe7vfU1teU75ZR+/cW9t2GS1e1T/XkULRv5PH5gchSGQ1NHO4imIbv5dzAgMBAAECggEACTjSS051BKUh44N2mLWpxJiWEfD7vdg3rLGg3tZWIJlg+5XYbN2myG+YtNtIZ1YRJZwsbjV7Vm2WgD/i0Yz05+nLIrllHZpeEVtY6WC/ma/RxKrRZJpNq8RLmSbiLjV1aU1FHMdgjefkCvjfxqXyaoIXyt0BGeAPi6087AZ4fUyKVYgPyGr53RnD8+4nCDaRhZYMCv6zpb+YVF3llZZNhvK7+hDLZX0WhUgIAzStzFsPZhDfJxW8MQFB4FNtmnJ4kpInkgIAROlfVvKIwRKwoCH+sveGjYdlZR/wTYt6HQoKudG9Qx2IssUcVGFwAsCiWM+81rfBDd5pMUwzyGQ9OQKBgQDHOp7Eio4M6LaPO1Uz6Ozlp28evWBVPaU+wk50p5SQl//pF0VgDkmrrt3Wu9IppBL6VObIzjOsZJrEVHXheA/1qqOVYm/m6nel1EUAqbIqxREtw+GJPoKp3Ql1CxK6pvm/KxOhJvCDIUNCZ4in+rvsCvquF784iIbQ33ED3hWi2wKBgQD19DbAL1Y6/XHXX17t6yZJVsIijmSOo5tjeNHouOSP5emgc8i2ESaW4WPIzkgi7EJ2aertgUkwIOpunYvMWYfn6zrYNaSuvCCZF+6oIiYPPXEVZJTnzGA/KsJtHeH6xtiGuettw6RnPxXvNZibJhfLdOqQvZmRDRTXh/MiRuelSQKBgQC154IbNd7pTnmRYb0zvlK+hRfiW0rfyX9dRBBaVsBBHWedrY+8Wo9NYEZQ0ADd4F8rjeWCJzPrDZh59hwDl5oK1pixxsUhc6d3E89FAawZfQFoZddBdn/bFGSUJ14camTR9UTg+SrUr8Q3l0yhA0AeDxA/cJM5zP47LCiGPXpHzQKBgQDV00sGKiE9h7nBFBjjntvaRqLgiArEN1iQUimruZJ7x9YkuIR2RNLXuXuWyD/OnLfrWonzkcKfJP6qzC0Nq4iMB+VQstJJVyS/9B537bhI55G4l4kdPIEwaWw+kQw1iUoVVu1mr//uAtp+7ImP2L43E54Z17v6bvT/rCGkWyBogQKBgQC6pqnciYteAE5KmWnPM9LWoEorSBPCzbWCVwuja7NbVoADUPvAnUeDgvKs8KpWvL+X3eRGSZXOBqjBMsdDPBnQzr5yZCI3Mv6Svg9RxBfuWw1mF1w2GAwK1r7+6ZDwxFqRUiVUACRRJ8S1kBa+CvNWm7UFi/7V1D4UDyKKmBU6Sw=='
# request-keyRSA 私钥)已外置到 Nacos rdms-system-server-secret.yaml不再硬编码进 git
debug: false