feat(project): 新增产品动态时间线接口并重构活动查询逻辑

- 新增 GET /project/product/{id}/activities/page 接口用于产品动态时间线分页查询
- 添加 ProductActivityTimelinePageReqVO 和 ProductActivityTimelineRespVO 数据传输对象
- 实现 ProductActivityTimelineQueryService 服务处理动态时间线查询逻辑
- 在 BizAuditLogMapper 中新增按业务类型和动作类型查询的方法
- 在 ProductStatusLogMapper 中新增按产品ID和动作类型查询的方法
- 将硬编码的活动类型常量抽取到 ObjectActivityConstants 统一管理
- 重构 ProductActivityQueryService 使用统一的常量和查询方法
- 更新 ProductMemberServiceImpl 和 ProductServiceImpl 使用新的活动常量
- 添加相应的单元测试验证新接口和查询逻辑的正确性
- 新增产品对象首页改版设计文档和产品动态时间线接口需求说明文档
This commit is contained in:
2026-04-24 15:43:38 +08:00
parent 0a6d70f7cf
commit ee732b97bf
16 changed files with 1640 additions and 64 deletions

View File

@@ -0,0 +1,86 @@
package com.njcn.rdms.module.project.constant;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Set;
/**
* 对象动态常量
*
* 说明:
* 1. 当前先承载产品对象首页时间线使用的 activityType / actionType 常量
* 2. 后续项目等对象复用同类动态能力时,继续按前缀分组扩展,不单独拆分枚举
*/
public final class ObjectActivityConstants {
private ObjectActivityConstants() {
}
// ========== 动态来源类型 ==========
public static final String ACTIVITY_TYPE_STATUS = "status";
public static final String ACTIVITY_TYPE_PRODUCT = "product";
public static final String ACTIVITY_TYPE_MEMBER = "member";
// ========== 审计业务类型 ==========
public static final String PRODUCT_BIZ_TYPE = "product";
public static final String MEMBER_BIZ_TYPE = "rdms_user_object_role";
// ========== 产品对象动作 ==========
public static final String PRODUCT_ACTION_CREATE = "create";
public static final String PRODUCT_ACTION_UPDATE = "update";
public static final String PRODUCT_ACTION_DELETE = "delete";
public static final String PRODUCT_ACTION_CHANGE_MANAGER = "change_manager";
// ========== 状态动作 ==========
public static final String STATUS_ACTION_PAUSE = "pause";
public static final String STATUS_ACTION_RESUME = "resume";
public static final String STATUS_ACTION_ARCHIVE = "archive";
public static final String STATUS_ACTION_ABANDON = "abandon";
// ========== 成员动作 ==========
public static final String MEMBER_ACTION_ADD = "add_member";
public static final String MEMBER_ACTION_UPDATE = "update_member";
public static final String MEMBER_ACTION_REMOVE = "remove_member";
public static final List<String> STATUS_ACTION_TYPES = List.of(
STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON);
public static final List<String> PRODUCT_TIMELINE_ACTION_TYPES = List.of(
PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER);
public static final List<String> MEMBER_TIMELINE_ACTION_TYPES = List.of(
MEMBER_ACTION_ADD, MEMBER_ACTION_REMOVE);
private static final Set<String> STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES);
public static boolean isStatusAction(String actionType) {
return STATUS_ACTION_TYPE_SET.contains(normalize(actionType));
}
public static String resolveActionName(String actionType) {
String normalizedActionType = normalize(actionType);
if (!StringUtils.hasText(normalizedActionType)) {
return actionType;
}
return switch (normalizedActionType) {
case PRODUCT_ACTION_CREATE -> "创建";
case PRODUCT_ACTION_UPDATE -> "更新";
case PRODUCT_ACTION_DELETE -> "删除";
case STATUS_ACTION_PAUSE -> "暂停";
case STATUS_ACTION_RESUME -> "恢复";
case STATUS_ACTION_ARCHIVE -> "归档";
case STATUS_ACTION_ABANDON -> "废弃";
case PRODUCT_ACTION_CHANGE_MANAGER -> "切换产品经理";
case MEMBER_ACTION_ADD -> "新增成员";
case MEMBER_ACTION_UPDATE -> "调整成员";
case MEMBER_ACTION_REMOVE -> "移出成员";
default -> normalizedActionType;
};
}
private static String normalize(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}

View File

@@ -4,6 +4,8 @@ import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
import com.njcn.rdms.module.project.service.product.ProductService;
@@ -49,6 +51,14 @@ public class ProductSettingController {
return success(productSettingService.getProductActivities(id, reqVO));
}
@GetMapping("/{id}/activities/page")
@Operation(summary = "获取产品动态时间线分页")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<PageResult<ProductActivityTimelineRespVO>> getProductActivityTimelinePage(
@PathVariable("id") Long id, @Valid ProductActivityTimelinePageReqVO reqVO) {
return success(productSettingService.getProductActivityTimelinePage(id, reqVO));
}
@PutMapping("/{id}/settings/base-info")
@Operation(summary = "更新产品设置基础信息")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")

View File

@@ -0,0 +1,35 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.activity;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 产品动态时间线分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductActivityTimelinePageReqVO extends PageParam {
@Schema(description = "动态类型", example = "status")
@Size(max = 16, message = "动态类型长度不能超过16个字符")
private String activityType;
@Schema(description = "动作编码数组")
private List<@Size(max = 32, message = "动作编码长度不能超过32个字符") String> actionTypes;
@Schema(description = "开始时间", example = "2026-03-24 00:00:00")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime startTime;
@Schema(description = "结束时间", example = "2026-04-23 23:59:59")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime endTime;
}

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.activity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 产品动态时间线 Response VO")
@Data
public class ProductActivityTimelineRespVO {
@Schema(description = "动态唯一标识", example = "status:11")
private String id;
@Schema(description = "动态类型", example = "status")
private String type;
@Schema(description = "动作编码", example = "pause")
private String actionType;
@Schema(description = "动作名称", example = "暂停")
private String actionName;
@Schema(description = "操作人用户编号", example = "1024")
private Long operatorUserId;
@Schema(description = "操作人名称", example = "张三")
private String operatorName;
@Schema(description = "目标成员用户编号,仅 member 类型返回", example = "2043945809271713793")
private Long targetUserId;
@Schema(description = "目标成员名称,仅 member 类型返回,读取缓存实时转换", example = "张三")
private String targetUserName;
@Schema(description = "发生时间", example = "2026-04-21 12:00:00")
private LocalDateTime occurredAt;
@Schema(description = "展示摘要", example = "张三执行了【暂停】:资源不足")
private String summary;
@Schema(description = "动作原因", example = "资源不足")
private String reason;
@Schema(description = "原状态", example = "active")
private String fromStatus;
@Schema(description = "目标状态", example = "paused")
private String toStatus;
@Schema(description = "补充详情")
private String details;
}

View File

@@ -22,6 +22,17 @@ public interface BizAuditLogMapper extends BaseMapperX<BizAuditLogDO> {
.orderByDesc(BizAuditLogDO::getId));
}
default List<BizAuditLogDO> selectListByBizAndActions(String bizType, Long bizId, List<String> actionTypes,
LocalDateTime startTime, LocalDateTime endTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
.eq(BizAuditLogDO::getBizId, bizId)
.inIfPresent(BizAuditLogDO::getActionType, actionTypes)
.betweenIfPresent(BaseDO::getCreateTime, startTime, endTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(BizAuditLogDO::getId));
}
default List<BizAuditLogDO> selectListByBizType(String bizType, String actionType, LocalDateTime[] operateTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
@@ -31,4 +42,14 @@ public interface BizAuditLogMapper extends BaseMapperX<BizAuditLogDO> {
.orderByDesc(BizAuditLogDO::getId));
}
default List<BizAuditLogDO> selectListByBizTypeAndActions(String bizType, List<String> actionTypes,
LocalDateTime startTime, LocalDateTime endTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
.inIfPresent(BizAuditLogDO::getActionType, actionTypes)
.betweenIfPresent(BaseDO::getCreateTime, startTime, endTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(BizAuditLogDO::getId));
}
}

View File

@@ -21,4 +21,14 @@ public interface ProductStatusLogMapper extends BaseMapperX<ProductStatusLogDO>
.orderByDesc(ProductStatusLogDO::getId));
}
default List<ProductStatusLogDO> selectListByProductIdAndActions(Long productId, List<String> actionTypes,
LocalDateTime startTime, LocalDateTime endTime) {
return selectList(new LambdaQueryWrapperX<ProductStatusLogDO>()
.eq(ProductStatusLogDO::getProductId, productId)
.inIfPresent(ProductStatusLogDO::getActionType, actionTypes)
.betweenIfPresent(BaseDO::getCreateTime, startTime, endTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(ProductStatusLogDO::getId));
}
}

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.PageUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
@@ -28,12 +29,8 @@ import java.util.stream.Collectors;
@Service
public class ProductActivityQueryService {
private static final String PRODUCT_OBJECT_TYPE = "product";
private static final String MEMBER_BIZ_TYPE = "rdms_user_object_role";
private static final String ACTIVITY_TYPE_STATUS = "status";
private static final String ACTIVITY_TYPE_PRODUCT = "product";
private static final String ACTIVITY_TYPE_MEMBER = "member";
private static final String PRODUCT_OBJECT_TYPE = ObjectActivityConstants.PRODUCT_BIZ_TYPE;
private static final String MEMBER_BIZ_TYPE = ObjectActivityConstants.MEMBER_BIZ_TYPE;
@Resource
private ProductStatusLogMapper productStatusLogMapper;
@@ -44,15 +41,15 @@ public class ProductActivityQueryService {
public PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO) {
List<ActivityItem> items = new ArrayList<>();
if (includeType(reqVO.getActivityType(), ACTIVITY_TYPE_STATUS)) {
if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_STATUS)) {
productStatusLogMapper.selectListByProductId(productId, reqVO.getActionType(), reqVO.getOperateTime())
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toStatusActivity(log))));
}
if (includeType(reqVO.getActivityType(), ACTIVITY_TYPE_PRODUCT)) {
if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT)) {
bizAuditLogMapper.selectListByBiz(PRODUCT_OBJECT_TYPE, productId, reqVO.getActionType(), reqVO.getOperateTime())
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toProductActivity(log))));
}
if (includeType(reqVO.getActivityType(), ACTIVITY_TYPE_MEMBER)) {
if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
appendMemberActivities(productId, reqVO, items);
}
@@ -110,9 +107,9 @@ public class ProductActivityQueryService {
private ProductActivityRespVO toStatusActivity(ProductStatusLogDO log) {
ProductActivityRespVO respVO = new ProductActivityRespVO();
respVO.setType(ACTIVITY_TYPE_STATUS);
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_STATUS);
respVO.setActionType(log.getActionType());
respVO.setActionName(resolveActionName(log.getActionType()));
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setReason(log.getReason());
@@ -133,9 +130,9 @@ public class ProductActivityQueryService {
private ProductActivityRespVO toProductActivity(BizAuditLogDO log) {
ProductActivityRespVO respVO = new ProductActivityRespVO();
respVO.setType(ACTIVITY_TYPE_PRODUCT);
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT);
respVO.setActionType(log.getActionType());
respVO.setActionName(resolveActionName(log.getActionType()));
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setReason(log.getReason());
@@ -149,30 +146,10 @@ public class ProductActivityQueryService {
private ProductActivityRespVO toMemberActivity(BizAuditLogDO log) {
ProductActivityRespVO respVO = toProductActivity(log);
respVO.setType(ACTIVITY_TYPE_MEMBER);
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_MEMBER);
return respVO;
}
private String resolveActionName(String actionType) {
if (!StringUtils.hasText(actionType)) {
return actionType;
}
return switch (actionType.trim()) {
case "create" -> "创建";
case "update" -> "更新";
case "delete" -> "删除";
case "pause" -> "暂停";
case "resume" -> "恢复";
case "archive" -> "归档";
case "abandon" -> "废弃";
case "change_manager" -> "切换产品经理";
case "add_member" -> "新增成员";
case "update_member" -> "调整成员";
case "remove_member" -> "移出成员";
default -> actionType.trim();
};
}
private String buildSummary(String operatorName, String actionName, String reason) {
String actualOperatorName = StringUtils.hasText(operatorName) ? operatorName : "系统";
if (StringUtils.hasText(reason)) {

View File

@@ -0,0 +1,414 @@
package com.njcn.rdms.module.project.service.product;
import com.fasterxml.jackson.databind.JsonNode;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.PageUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
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.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service
public class ProductActivityTimelineQueryService {
/**
* 成员名称在读取时间线时再通过缓存转换,避免把昵称快照写进动态记录。
*/
private static final String TIMELINE_USER_NICKNAME_CACHE = "project_timeline_user_nickname#10m";
@Resource
private ProductStatusLogMapper productStatusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private CacheManager cacheManager;
@Resource
private AdminUserApi adminUserApi;
public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO) {
LocalDateTime[] timeRange = buildTimeRange(reqVO);
List<String> actionTypes = normalizeActionTypes(reqVO.getActionTypes());
List<ActivityItem> items = new ArrayList<>();
appendStatusActivities(productId, reqVO.getActivityType(), actionTypes, timeRange, items);
appendProductActivities(productId, reqVO.getActivityType(), actionTypes, timeRange, items);
appendMemberActivities(productId, reqVO.getActivityType(), actionTypes, timeRange, items);
items.sort(Comparator.comparing(ActivityItem::occurredAt, Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(ActivityItem::sourceId, Comparator.nullsLast(Long::compareTo))
.reversed());
PageResult<ProductActivityTimelineRespVO> pageResult =
buildPageResult(items.stream().map(ActivityItem::respVO).toList(), reqVO);
fillTargetUserNames(pageResult.getList());
return pageResult;
}
private void appendStatusActivities(Long productId, String activityType, List<String> actionTypes,
LocalDateTime[] timeRange, List<ActivityItem> items) {
if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_STATUS)) {
return;
}
List<String> statusActions = limitActions(actionTypes, ObjectActivityConstants.STATUS_ACTION_TYPES);
if (shouldSkipByIntersection(actionTypes, statusActions)) {
return;
}
productStatusLogMapper.selectListByProductIdAndActions(productId, statusActions, timeRange[0], timeRange[1])
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toStatusTimeline(log))));
}
private void appendProductActivities(Long productId, String activityType, List<String> actionTypes,
LocalDateTime[] timeRange, List<ActivityItem> items) {
if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT)) {
return;
}
List<String> productActions = limitActions(actionTypes, ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES);
if (shouldSkipByIntersection(actionTypes, productActions)) {
return;
}
List<BizAuditLogDO> productLogs = bizAuditLogMapper.selectListByBizAndActions(
ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId, productActions, timeRange[0], timeRange[1]);
Set<CreateSignature> createSignatures = buildCreateSignatures(productLogs);
for (BizAuditLogDO log : productLogs) {
if (ObjectActivityConstants.isStatusAction(log.getActionType())) {
continue;
}
if (isCreateInitManagerNoise(log, createSignatures)) {
continue;
}
if (!ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES.contains(trim(log.getActionType()))) {
continue;
}
items.add(new ActivityItem(log.getId(), log.getCreateTime(), toProductTimeline(log)));
}
}
private void appendMemberActivities(Long productId, String activityType, List<String> actionTypes,
LocalDateTime[] timeRange, List<ActivityItem> items) {
if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
return;
}
List<String> memberActions = limitActions(actionTypes, ObjectActivityConstants.MEMBER_TIMELINE_ACTION_TYPES);
if (shouldSkipByIntersection(actionTypes, memberActions)) {
return;
}
List<BizAuditLogDO> memberLogs = bizAuditLogMapper.selectListByBizTypeAndActions(
ObjectActivityConstants.MEMBER_BIZ_TYPE, memberActions, timeRange[0], timeRange[1]);
if (memberLogs.isEmpty()) {
return;
}
Map<Long, UserObjectRoleDO> memberMap = loadMemberMap(productId, memberLogs);
Set<CreateSignature> createSignatures = loadCreateSignatures(productId, timeRange);
for (BizAuditLogDO log : memberLogs) {
UserObjectRoleDO member = memberMap.get(log.getBizId());
if (member == null) {
continue;
}
if (!ObjectActivityConstants.MEMBER_TIMELINE_ACTION_TYPES.contains(trim(log.getActionType()))) {
continue;
}
if (isCreateInitMemberNoise(log, createSignatures)) {
continue;
}
items.add(new ActivityItem(log.getId(), log.getCreateTime(), toMemberTimeline(log, member)));
}
}
private LocalDateTime[] buildTimeRange(ProductActivityTimelinePageReqVO reqVO) {
if ((reqVO.getStartTime() == null) != (reqVO.getEndTime() == null)) {
throw invalidParamException("开始时间和结束时间必须同时传入");
}
if (reqVO.getStartTime() == null) {
LocalDateTime endTime = LocalDateTime.now();
return new LocalDateTime[]{endTime.minusDays(30), endTime};
}
if (reqVO.getStartTime().isAfter(reqVO.getEndTime())) {
throw invalidParamException("开始时间不能晚于结束时间");
}
return new LocalDateTime[]{reqVO.getStartTime(), reqVO.getEndTime()};
}
private List<String> normalizeActionTypes(List<String> actionTypes) {
if (actionTypes == null || actionTypes.isEmpty()) {
return null;
}
List<String> normalized = actionTypes.stream()
.filter(StringUtils::hasText)
.map(String::trim)
.distinct()
.toList();
return normalized.isEmpty() ? null : normalized;
}
private List<String> limitActions(List<String> actionTypes, List<String> allowedActions) {
if (actionTypes == null || actionTypes.isEmpty()) {
return allowedActions;
}
return actionTypes.stream()
.filter(allowedActions::contains)
.distinct()
.toList();
}
private boolean shouldSkipByIntersection(List<String> actualActions, List<String> limitedActions) {
return actualActions != null && actualActions.size() > 0 && limitedActions.isEmpty();
}
private boolean includeType(String actual, String expected) {
return !StringUtils.hasText(actual) || Objects.equals(actual.trim(), expected);
}
private Map<Long, UserObjectRoleDO> loadMemberMap(Long productId, List<BizAuditLogDO> memberLogs) {
List<Long> memberIds = memberLogs.stream()
.map(BizAuditLogDO::getBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (memberIds.isEmpty()) {
return Map.of();
}
Map<Long, UserObjectRoleDO> memberMap = new LinkedHashMap<>();
for (UserObjectRoleDO member : userObjectRoleMapper.selectListByIdsAndObject(
memberIds, ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId)) {
memberMap.put(member.getId(), member);
}
return memberMap;
}
private Set<CreateSignature> loadCreateSignatures(Long productId, LocalDateTime[] timeRange) {
List<BizAuditLogDO> productLogs = bizAuditLogMapper.selectListByBizAndActions(
ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId,
ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES, timeRange[0], timeRange[1]);
return buildCreateSignatures(productLogs);
}
private Set<CreateSignature> buildCreateSignatures(List<BizAuditLogDO> productLogs) {
Set<CreateSignature> signatures = new LinkedHashSet<>();
for (BizAuditLogDO log : productLogs) {
if (!Objects.equals(trim(log.getActionType()), ObjectActivityConstants.PRODUCT_ACTION_CREATE)) {
continue;
}
Long managerUserId = getFieldChangeLong(log.getFieldChanges(), "managerUserId", "after");
if (managerUserId == null || log.getCreateTime() == null) {
continue;
}
signatures.add(new CreateSignature(log.getCreateTime(), managerUserId));
}
return signatures;
}
private boolean isCreateInitManagerNoise(BizAuditLogDO log, Set<CreateSignature> createSignatures) {
if (!Objects.equals(trim(log.getActionType()), ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER)) {
return false;
}
if (getFieldChangeLong(log.getFieldChanges(), "managerUserId", "before") != null) {
return false;
}
Long managerUserId = getFieldChangeLong(log.getFieldChanges(), "managerUserId", "after");
return managerUserId != null && log.getCreateTime() != null
&& createSignatures.contains(new CreateSignature(log.getCreateTime(), managerUserId));
}
private boolean isCreateInitMemberNoise(BizAuditLogDO log, Set<CreateSignature> createSignatures) {
if (!Objects.equals(trim(log.getActionType()), ObjectActivityConstants.MEMBER_ACTION_ADD)) {
return false;
}
if (getFieldChangeLong(log.getFieldChanges(), "userId", "before") != null) {
return false;
}
Long userId = getFieldChangeLong(log.getFieldChanges(), "userId", "after");
return userId != null && log.getCreateTime() != null
&& createSignatures.contains(new CreateSignature(log.getCreateTime(), userId));
}
private Long getFieldChangeLong(String fieldChanges, String fieldName, String valueField) {
JsonNode valueNode = getFieldChangeNode(fieldChanges, fieldName, valueField);
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isNumber()) {
return valueNode.longValue();
}
if (valueNode.isTextual() && StringUtils.hasText(valueNode.textValue())) {
return Long.valueOf(valueNode.textValue().trim());
}
return null;
}
private JsonNode getFieldChangeNode(String fieldChanges, String fieldName, String valueField) {
if (!StringUtils.hasText(fieldChanges) || !JsonUtils.isJsonObject(fieldChanges)) {
return null;
}
JsonNode fieldNode = JsonUtils.parseTree(fieldChanges).path(fieldName);
if (fieldNode.isMissingNode()) {
return null;
}
JsonNode valueNode = fieldNode.path(valueField);
return valueNode.isMissingNode() ? null : valueNode;
}
private PageResult<ProductActivityTimelineRespVO> buildPageResult(List<ProductActivityTimelineRespVO> activities,
ProductActivityTimelinePageReqVO reqVO) {
if (activities.isEmpty()) {
return PageResult.empty();
}
int start = PageUtils.getStart(reqVO);
if (start >= activities.size()) {
return PageResult.empty((long) activities.size());
}
int end = Math.min(start + reqVO.getPageSize(), activities.size());
return new PageResult<>(activities.subList(start, end), (long) activities.size());
}
private void fillTargetUserNames(List<ProductActivityTimelineRespVO> activities) {
if (activities == null || activities.isEmpty()) {
return;
}
Set<Long> userIds = new LinkedHashSet<>();
for (ProductActivityTimelineRespVO activity : activities) {
if (activity != null && activity.getTargetUserId() != null) {
userIds.add(activity.getTargetUserId());
}
}
if (userIds.isEmpty()) {
return;
}
Map<Long, String> nicknameMap = loadUserNicknameMap(userIds);
for (ProductActivityTimelineRespVO activity : activities) {
if (activity == null || activity.getTargetUserId() == null) {
continue;
}
activity.setTargetUserName(nicknameMap.get(activity.getTargetUserId()));
}
}
private Map<Long, String> loadUserNicknameMap(Set<Long> userIds) {
Map<Long, String> nicknameMap = new LinkedHashMap<>();
if (userIds == null || userIds.isEmpty()) {
return nicknameMap;
}
Cache cache = cacheManager == null ? null : cacheManager.getCache(TIMELINE_USER_NICKNAME_CACHE);
Set<Long> missIds = new LinkedHashSet<>();
for (Long userId : userIds) {
String nickname = cache == null ? null : cache.get(userId, String.class);
if (nickname != null) {
nicknameMap.put(userId, nickname);
} else {
missIds.add(userId);
}
}
if (missIds.isEmpty()) {
return nicknameMap;
}
Map<Long, AdminUserRespDTO> userMap = adminUserApi == null ? Map.of() : adminUserApi.getUserMap(missIds);
if (userMap == null || userMap.isEmpty()) {
return nicknameMap;
}
for (Long userId : missIds) {
AdminUserRespDTO user = userMap.get(userId);
if (user == null) {
continue;
}
nicknameMap.put(userId, user.getNickname());
if (cache != null && user.getNickname() != null) {
cache.put(userId, user.getNickname());
}
}
return nicknameMap;
}
private ProductActivityTimelineRespVO toStatusTimeline(ProductStatusLogDO log) {
ProductActivityTimelineRespVO respVO = new ProductActivityTimelineRespVO();
respVO.setId(ObjectActivityConstants.ACTIVITY_TYPE_STATUS + ":" + log.getId());
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_STATUS);
respVO.setActionType(log.getActionType());
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setOperatorUserId(log.getOperatorUserId());
respVO.setOperatorName(log.getOperatorName());
respVO.setOccurredAt(log.getCreateTime());
respVO.setSummary(buildSummary(log.getOperatorName(), respVO.getActionName(), log.getReason()));
respVO.setReason(log.getReason());
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setDetails(JsonUtils.toJsonString(buildStatusDetails(log)));
return respVO;
}
private ProductActivityTimelineRespVO toProductTimeline(BizAuditLogDO log) {
ProductActivityTimelineRespVO respVO = new ProductActivityTimelineRespVO();
respVO.setId(ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT + ":" + log.getId());
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT);
respVO.setActionType(log.getActionType());
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setOperatorUserId(log.getOperatorUserId());
respVO.setOperatorName(log.getOperatorName());
respVO.setOccurredAt(log.getCreateTime());
respVO.setSummary(buildSummary(log.getOperatorName(), respVO.getActionName(), log.getReason()));
respVO.setReason(log.getReason());
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setDetails(log.getFieldChanges());
return respVO;
}
private ProductActivityTimelineRespVO toMemberTimeline(BizAuditLogDO log, UserObjectRoleDO member) {
ProductActivityTimelineRespVO respVO = toProductTimeline(log);
respVO.setId(ObjectActivityConstants.ACTIVITY_TYPE_MEMBER + ":" + log.getId());
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_MEMBER);
respVO.setTargetUserId(member == null ? null : member.getUserId());
return respVO;
}
private Map<String, Object> buildStatusDetails(ProductStatusLogDO log) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("productCodeSnapshot", log.getProductCodeSnapshot());
details.put("productNameSnapshot", log.getProductNameSnapshot());
return details;
}
private String buildSummary(String operatorName, String actionName, String reason) {
String actualOperatorName = StringUtils.hasText(operatorName) ? operatorName : "系统";
if (StringUtils.hasText(reason)) {
return String.format("%s执行了【%s】%s", actualOperatorName, actionName, reason);
}
return String.format("%s执行了【%s】", actualOperatorName, actionName);
}
private String trim(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private record CreateSignature(LocalDateTime occurredAt, Long userId) {
}
private record ActivityItem(Long sourceId, LocalDateTime occurredAt, ProductActivityTimelineRespVO respVO) {
}
}

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
@@ -51,13 +52,6 @@ public class ProductMemberServiceImpl implements ProductMemberService {
private static final String PRODUCT_MANAGER_ROLE_CODE = "product_manager";
private static final String AUDIT_BIZ_TYPE_MEMBER = "rdms_user_object_role";
private static final String AUDIT_BIZ_TYPE_PRODUCT = "product";
private static final String AUDIT_ACTION_ADD_MEMBER = "add_member";
private static final String AUDIT_ACTION_UPDATE_MEMBER = "update_member";
private static final String AUDIT_ACTION_REMOVE_MEMBER = "remove_member";
private static final String AUDIT_ACTION_CHANGE_MANAGER = "change_manager";
@Resource
private ProductMapper productMapper;
@Resource
@@ -135,7 +129,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, AUDIT_ACTION_ADD_MEMBER, before, member, null);
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
if (isManagerRole(targetRole)) {
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
}
@@ -170,7 +164,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, AUDIT_ACTION_UPDATE_MEMBER, before, member, normalizeNullableText(reqVO.getReason()));
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_UPDATE,
before, member, normalizeNullableText(reqVO.getReason()));
}
@Override
@@ -192,7 +187,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(LocalDateTime.now());
userObjectRoleMapper.updateById(member);
writeMemberAuditLog(member, AUDIT_ACTION_REMOVE_MEMBER, before, member,
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_REMOVE, before, member,
normalizeNullableText(reqVO.getReason()));
}
@@ -283,7 +278,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(null);
member.setRemark(null);
userObjectRoleMapper.insert(member);
actionType = AUDIT_ACTION_ADD_MEMBER;
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
member = existingMember;
member.setRoleId(previousManagerRoleId);
@@ -291,9 +286,9 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(null);
if (!Objects.equals(before.getStatus(), MEMBER_STATUS_ACTIVE)) {
member.setJoinedTime(now);
actionType = AUDIT_ACTION_ADD_MEMBER;
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
actionType = AUDIT_ACTION_UPDATE_MEMBER;
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
}
userObjectRoleMapper.updateById(member);
}
@@ -330,7 +325,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
UserObjectRoleDO after,
String reason) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(AUDIT_BIZ_TYPE_MEMBER);
auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE);
auditLog.setBizId(member.getId());
auditLog.setActionType(actionType);
auditLog.setFieldChanges(buildMemberFieldChanges(before, after));
@@ -345,9 +340,9 @@ public class ProductMemberServiceImpl implements ProductMemberService {
return;
}
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(AUDIT_BIZ_TYPE_PRODUCT);
auditLog.setBizType(PRODUCT_OBJECT_TYPE);
auditLog.setBizId(productId);
auditLog.setActionType(AUDIT_ACTION_CHANGE_MANAGER);
auditLog.setActionType(ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER);
auditLog.setFieldChanges(buildManagerFieldChanges(beforeManagerUserId, afterManagerUserId));
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());

View File

@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextNavRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextProductRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRoleRespVO;
@@ -71,13 +72,6 @@ public class ProductServiceImpl implements ProductService {
private static final Integer MEMBER_STATUS_ACTIVE = 0;
private static final String PRODUCT_CREATE_ACTION = "create";
private static final String PRODUCT_UPDATE_ACTION = "update";
private static final String PRODUCT_DELETE_ACTION = "delete";
private static final String PRODUCT_CHANGE_MANAGER_ACTION = "change_manager";
private static final String PRODUCT_ADD_MEMBER_ACTION = "add_member";
private static final String AUDIT_BIZ_TYPE_MEMBER = "rdms_user_object_role";
private static final String PRODUCT_CODE_PREFIX = "CNPD";
private static final String PRODUCT_QUERY_PERMISSION = "project:product:query";
private static final String PRODUCT_UPDATE_PERMISSION = "project:product:update";
@@ -116,7 +110,7 @@ public class ProductServiceImpl implements ProductService {
productMapper.insert(product);
initManagerMemberRelation(product);
writeBizAuditLog(product, PRODUCT_CREATE_ACTION, null, PRODUCT_ACTIVE_STATUS,
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, PRODUCT_ACTIVE_STATUS,
buildProductFieldChanges(null, product), null);
return product.getId();
}
@@ -227,8 +221,8 @@ public class ProductServiceImpl implements ProductService {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_CONCURRENT_MODIFIED);
}
writeProductStatusLog(product, PRODUCT_DELETE_ACTION, fromStatus, null, reason);
writeBizAuditLog(product, PRODUCT_DELETE_ACTION, fromStatus, null, null, reason);
writeProductStatusLog(product, ObjectActivityConstants.PRODUCT_ACTION_DELETE, fromStatus, null, reason);
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_DELETE, fromStatus, null, null, reason);
}
@VisibleForTesting
@@ -475,9 +469,9 @@ public class ProductServiceImpl implements ProductService {
private void writeMemberInitAuditLog(UserObjectRoleDO member) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(AUDIT_BIZ_TYPE_MEMBER);
auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE);
auditLog.setBizId(member.getId());
auditLog.setActionType(PRODUCT_ADD_MEMBER_ACTION);
auditLog.setActionType(ObjectActivityConstants.MEMBER_ACTION_ADD);
auditLog.setFieldChanges(buildMemberFieldChanges(member));
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
@@ -488,7 +482,7 @@ public class ProductServiceImpl implements ProductService {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(PRODUCT_OBJECT_TYPE);
auditLog.setBizId(productId);
auditLog.setActionType(PRODUCT_CHANGE_MANAGER_ACTION);
auditLog.setActionType(ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER);
auditLog.setFieldChanges(buildManagerFieldChanges(null, managerUserId));
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
@@ -518,7 +512,7 @@ public class ProductServiceImpl implements ProductService {
product.setDescription(normalizeNullableText(description));
productMapper.updateById(product);
writeBizAuditLog(product, PRODUCT_UPDATE_ACTION, before.getStatusCode(), product.getStatusCode(),
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_UPDATE, before.getStatusCode(), product.getStatusCode(),
buildProductFieldChanges(before, product), null);
}

View File

@@ -3,6 +3,8 @@ package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
/**
@@ -27,4 +29,14 @@ public interface ProductSettingService {
*/
PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO);
/**
* 获取产品动态时间线分页
*
* @param productId 产品编号
* @param reqVO 查询参数
* @return 产品动态时间线分页
*/
PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO);
}

View File

@@ -5,6 +5,8 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
@@ -32,6 +34,8 @@ public class ProductSettingServiceImpl implements ProductSettingService {
private ProductStatusViewService productStatusViewService;
@Resource
private ProductActivityQueryService productActivityQueryService;
@Resource
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
@@ -52,6 +56,15 @@ public class ProductSettingServiceImpl implements ProductSettingService {
return productActivityQueryService.getProductActivities(productId, reqVO);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO) {
validateProductExists(productId);
return productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
}
private ProductDO validateProductExists(Long productId) {
if (productId == null) {
throw exception(ErrorCodeConstants.PRODUCT_NOT_EXISTS);

View File

@@ -0,0 +1,350 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
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.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProductActivityTimelineQueryServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Mock
private ProductStatusLogMapper productStatusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private CacheManager cacheManager;
@Mock
private Cache userNicknameCache;
@Mock
private AdminUserApi adminUserApi;
@Test
void getProductActivityTimelinePage_whenTimeRangeMissingEnd_shouldThrowInvalidParam() {
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setStartTime(LocalDateTime.of(2026, 4, 1, 0, 0, 0));
ServiceException ex = assertThrows(ServiceException.class,
() -> productActivityTimelineQueryService.getProductActivityTimelinePage(1001L, reqVO));
assertTrue(ex.getMessage().contains("开始时间和结束时间必须同时传入"));
}
@Test
void getProductActivityTimelinePage_shouldUseDefaultRecent30DaysAndActionTypes() {
Long productId = 1001L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("status");
reqVO.setActionTypes(List.of("pause", "archive"));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), eq(List.of("pause", "archive")),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
ArgumentCaptor<LocalDateTime> startCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> endCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(productStatusLogMapper).selectListByProductIdAndActions(eq(productId), eq(List.of("pause", "archive")),
startCaptor.capture(), endCaptor.capture());
assertEquals(0L, result.getTotal());
assertTrue(Duration.between(startCaptor.getValue(), endCaptor.getValue()).toDays() >= 29);
assertTrue(Duration.between(startCaptor.getValue(), endCaptor.getValue()).toDays() <= 31);
}
@Test
void getProductActivityTimelinePage_shouldKeepSingleCreateAndIgnoreInitNoise() {
Long productId = 1002L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
BizAuditLogDO createLog = buildProductLog(21L, productId, "create",
"{\"managerUserId\":{\"before\":null,\"after\":2001}}",
LocalDateTime.of(2026, 4, 23, 10, 0, 0));
BizAuditLogDO managerInitLog = buildProductLog(22L, productId, "change_manager",
"{\"managerUserId\":{\"before\":null,\"after\":2001}}",
LocalDateTime.of(2026, 4, 23, 10, 0, 0));
BizAuditLogDO memberInitLog = buildMemberLog(31L, 9001L, "add_member",
"{\"userId\":{\"before\":null,\"after\":2001}}",
LocalDateTime.of(2026, 4, 23, 10, 0, 0));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
when(bizAuditLogMapper.selectListByBizAndActions(eq("product"), eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(managerInitLog, createLog));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(memberInitLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9001L), "product", productId))
.thenReturn(List.of(buildMember(9001L, productId, 2001L)));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("product", result.getList().get(0).getType());
assertEquals("create", result.getList().get(0).getActionType());
}
@Test
void getProductActivityTimelinePage_shouldPreferStatusLogOverProductStatusAudit() {
Long productId = 1003L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProductStatusLogDO statusLog = buildStatusLog(11L, productId, "pause",
"active", "paused", LocalDateTime.of(2026, 4, 22, 9, 0, 0));
BizAuditLogDO statusAudit = buildProductLog(12L, productId, "pause", null,
LocalDateTime.of(2026, 4, 22, 9, 0, 0));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(statusLog));
when(bizAuditLogMapper.selectListByBizAndActions(eq("product"), eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(statusAudit));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("status:11", result.getList().get(0).getId());
}
@Test
void getProductActivityTimelinePage_shouldFilterMemberActionsByIntersection() {
Long productId = 1004L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
reqVO.setActionTypes(List.of("remove_member"));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), eq(List.of("remove_member")),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(0L, result.getTotal());
verify(bizAuditLogMapper).selectListByBizTypeAndActions(eq("rdms_user_object_role"),
eq(List.of("remove_member")), any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void getProductActivityTimelinePage_shouldExcludeUpdateMemberAction() {
Long productId = 1005L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
BizAuditLogDO updateMemberLog = buildMemberLog(41L, 9002L, "update_member",
"{\"remark\":{\"before\":\"\",\"after\":\"\"}}",
LocalDateTime.of(2026, 4, 21, 15, 0, 0));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
when(bizAuditLogMapper.selectListByBizAndActions(eq("product"), eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(updateMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9002L), "product", productId))
.thenReturn(List.of(buildMember(9002L, productId, 2002L)));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(0L, result.getTotal());
}
@Test
void getProductActivityTimelinePage_shouldExposeTargetUserIdForMemberActivity() {
Long productId = 1006L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO addMemberLog = buildMemberLog(51L, 9003L, "add_member",
"{\"userId\":{\"before\":null,\"after\":2003}}",
LocalDateTime.of(2026, 4, 21, 16, 0, 0));
UserObjectRoleDO member = buildMember(9003L, productId, 2003L);
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(addMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9003L), "product", productId))
.thenReturn(List.of(member));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals(2003L, result.getList().get(0).getTargetUserId());
}
@Test
void getProductActivityTimelinePage_shouldReadTargetUserNameFromCacheFirst() {
Long productId = 1007L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO addMemberLog = buildMemberLog(61L, 9004L, "add_member",
"{\"userId\":{\"before\":null,\"after\":2004}}",
LocalDateTime.of(2026, 4, 21, 17, 0, 0));
UserObjectRoleDO member = buildMember(9004L, productId, 2004L);
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(addMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9004L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(userNicknameCache.get(2004L, String.class)).thenReturn("成员丁");
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("成员丁", new BeanWrapperImpl(result.getList().get(0)).getPropertyValue("targetUserName"));
verify(adminUserApi, never()).getUserMap(anySet());
}
@Test
void getProductActivityTimelinePage_shouldLoadAndCacheTargetUserNameWhenCacheMiss() {
Long productId = 1008L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO removeMemberLog = buildMemberLog(71L, 9005L, "remove_member",
"{\"userId\":{\"before\":2005,\"after\":2005}}",
LocalDateTime.of(2026, 4, 21, 18, 0, 0));
UserObjectRoleDO member = buildMember(9005L, productId, 2005L);
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(removeMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9005L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(userNicknameCache.get(2005L, String.class)).thenReturn(null);
when(adminUserApi.getUserMap(java.util.Set.of(2005L))).thenReturn(Map.of(2005L, buildUser(2005L, "成员戊")));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("成员戊", new BeanWrapperImpl(result.getList().get(0)).getPropertyValue("targetUserName"));
verify(userNicknameCache).put(2005L, "成员戊");
}
private BizAuditLogDO buildProductLog(Long id, Long productId, String actionType,
String fieldChanges, LocalDateTime createTime) {
BizAuditLogDO log = new BizAuditLogDO();
log.setId(id);
log.setBizType("product");
log.setBizId(productId);
log.setActionType(actionType);
log.setFieldChanges(fieldChanges);
log.setOperatorUserId(100L);
log.setOperatorName("张三");
log.setCreateTime(createTime);
return log;
}
private BizAuditLogDO buildMemberLog(Long id, Long memberId, String actionType,
String fieldChanges, LocalDateTime createTime) {
BizAuditLogDO log = new BizAuditLogDO();
log.setId(id);
log.setBizType("rdms_user_object_role");
log.setBizId(memberId);
log.setActionType(actionType);
log.setFieldChanges(fieldChanges);
log.setOperatorUserId(100L);
log.setOperatorName("张三");
log.setCreateTime(createTime);
return log;
}
private ProductStatusLogDO buildStatusLog(Long id, Long productId, String actionType,
String fromStatus, String toStatus, LocalDateTime createTime) {
ProductStatusLogDO log = new ProductStatusLogDO();
log.setId(id);
log.setProductId(productId);
log.setActionType(actionType);
log.setFromStatus(fromStatus);
log.setToStatus(toStatus);
log.setOperatorUserId(101L);
log.setOperatorName("李四");
log.setCreateTime(createTime);
return log;
}
private UserObjectRoleDO buildMember(Long id, Long productId, Long userId) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(id);
member.setObjectType("product");
member.setObjectId(productId);
member.setUserId(userId);
return member;
}
private AdminUserRespDTO buildUser(Long id, String nickname) {
AdminUserRespDTO user = new AdminUserRespDTO();
user.setId(id);
user.setNickname(nickname);
return user;
}
}

View File

@@ -5,6 +5,8 @@ import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
@@ -40,6 +42,8 @@ class ProductSettingServiceImplTest extends BaseMockitoUnitTest {
private ProductStatusViewService productStatusViewService;
@Mock
private ProductActivityQueryService productActivityQueryService;
@Mock
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Test
void getProductSettings_shouldAssembleBaseInfoAndLifecycleActions() {
@@ -169,4 +173,43 @@ class ProductSettingServiceImplTest extends BaseMockitoUnitTest {
assertEquals(ErrorCodeConstants.PRODUCT_NOT_EXISTS.getCode(), ex.getCode());
}
@Test
void getProductActivityTimelinePage_shouldValidateProductAndDelegate() {
Long productId = 1006L;
ProductDO product = new ProductDO();
product.setId(productId);
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProductActivityTimelineRespVO respVO = new ProductActivityTimelineRespVO();
respVO.setId("status:11");
PageResult<ProductActivityTimelineRespVO> pageResult = new PageResult<>(List.of(respVO), 1L);
when(productMapper.selectById(productId)).thenReturn(product);
when(productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO))
.thenReturn(pageResult);
PageResult<ProductActivityTimelineRespVO> result =
productSettingService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("status:11", result.getList().get(0).getId());
}
@Test
void getProductActivityTimelinePage_whenProductMissing_shouldThrowException() {
Long productId = 1007L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
when(productMapper.selectById(productId)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> productSettingService.getProductActivityTimelinePage(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_NOT_EXISTS.getCode(), ex.getCode());
}
}