# Conflicts:
#	rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java
This commit is contained in:
dk
2026-04-28 16:58:35 +08:00
17 changed files with 2072 additions and 65 deletions

View File

@@ -0,0 +1,270 @@
# 产品动态时间线后端接口需求说明
日期2026-04-23
## 1. 背景
当前产品对象首页中的“产品动态时间线”模块,用户期望不是展示几条前端拼装的摘要,而是一个可以在首页内直接查询的正式动态模块。
本轮已确认的目标能力包括:
- 首页内直接展示产品动态时间线
- 默认查询最近 `30 天`
- 支持自定义起止时间筛选
- 支持事件类型多选筛选
- 支持分页
- 第一版事件范围收敛在产品对象与团队关系,不混入需求池事件
## 2. 当前前端现状
当前前端可直接使用的真实接口只有:
- `GET /project/product/get`
- `GET /project/product/{id}/settings`
- `GET /project/product/{id}/members`
这些接口目前只能提供:
- 产品当前状态
- 最近一次状态原因
- 产品经理
- 成员加入时间
- 成员退出时间
- 当前成员角色
它们可以勉强拼出少量“最近动态摘要”,但不足以支撑正式时间线查询模块。
## 3. 当前接口为什么不够
如果继续只依赖现有接口,前端存在以下硬缺口:
1. 没有统一的产品动态分页接口
2. 没有事件类型维度,无法支持类型筛选
3. 没有统一发生时间字段集合,无法稳定支持时间筛选
4. 没有事件操作人字段,无法明确展示“谁做了什么”
5. 没有统一的事件摘要字段,前端只能自己硬拼文案
6. 没有产品状态变更前后值,无法展示“从什么状态变更为什么状态”
7. 没有产品经理变更前后值,无法展示交接关系
8. 没有分页总数字段,无法做首页内翻页
因此,现有接口只适合做“概览摘要”,不适合做“可查询产品动态时间线”。
## 4. 后端接口交付要求
后端必须新增一条专用分页接口:
`GET /project/product/{id}/activities/page`
这条接口只服务“产品动态时间线”能力,不承担需求池动态,不承担首页其它概览指标。
前端要求后端单独提供这条接口,原因如下:
- 语义清晰,前后端都容易维护
- 首页时间线可直接消费
- 后续如果要做独立的产品动态页,也可以继续复用这条接口
- 不需要继续让前端从多个接口里拼装事件
## 5. 第一版事件范围
第一版事件类型确认收敛为 5 类:
- `product_created`:产品创建
- `product_status_changed`:产品状态变更
- `product_manager_changed`:产品经理变更
- `product_member_joined`:成员加入
- `product_member_removed`:成员移出
第一版明确不纳入:
- 成员角色调整
- 需求新增
- 需求状态流转
- 需求关闭
- 里程碑事件
- 风险点事件
## 6. 查询参数要求
接口必须支持以下查询参数:
- `pageNo`:页码
- `pageSize`:每页数量
- `startTime`:开始时间
- `endTime`:结束时间
- `types`:事件类型数组,多选
补充要求:
- 当前端未传时间范围时,后端默认按最近 `30 天` 返回
- 返回结果按 `occurredAt desc` 倒序排列
- `types` 支持多选,不要求前端单选
示例:
```http
GET /project/product/1001/activities/page?pageNo=1&pageSize=10&startTime=2026-03-24 00:00:00&endTime=2026-04-23 23:59:59&types=product_status_changed&types=product_manager_changed
```
## 7. 返回结构要求
接口返回必须支持分页,分页结构至少应满足:
```json
{
"total": 128,
"list": [
{
"id": "act_001",
"type": "product_status_changed",
"title": "产品状态变更",
"operatorId": "10001",
"operatorName": "张敏",
"occurredAt": "2026-04-23 10:32:15",
"summary": "产品状态由暂停变更为启用",
"reason": "测试恢复",
"beforeStatus": "paused",
"afterStatus": "active"
}
]
}
```
分页顶层字段至少包括:
- `total`
- `list`
如果后端已有统一分页模型,可以沿用现有分页结构,但前端必须能稳定拿到总数和列表。
## 8. 事件通用字段要求
无论哪种事件类型,后端都应统一返回以下字段:
- `id`:事件唯一 ID
- `type`:事件类型编码
- `title`:事件标题
- `operatorId`:操作人 ID
- `operatorName`:操作人名称
- `occurredAt`:发生时间
- `summary`:事件摘要
- `reason`:原因或备注,可为空
这些字段是首页时间线最小可展示集合。
## 9. 各事件类型的专属字段要求
### 9.1 产品创建 `product_created`
必须补充:
- `creatorUserId`
- `creatorUserName`
### 9.2 产品状态变更 `product_status_changed`
这是当前最关键的一类事件,后端必须返回:
- `beforeStatus`
- `afterStatus`
- `reason`
前端需要用这组字段明确表达:
- 变更前状态
- 变更后状态
- 本次变更原因
例如:
- `暂停 -> 启用`
- `启用 -> 归档`
### 9.3 产品经理变更 `product_manager_changed`
必须返回:
- `beforeManagerUserId`
- `beforeManagerUserName`
- `afterManagerUserId`
- `afterManagerUserName`
- `reason`
否则前端无法准确展示交接关系,只能看到当前经理,不能看到变更前后关系。
### 9.4 成员加入 `product_member_joined`
必须返回:
- `memberUserId`
- `memberUserName`
- `roleId`
- `roleName`
- `remark`
### 9.5 成员移出 `product_member_removed`
必须返回:
- `memberUserId`
- `memberUserName`
- `roleId`
- `roleName`
- `reason`
## 10. 前端展示口径
前端首页时间线模块第一版会直接基于这条接口支持:
- 默认最近 `30 天`
- 自定义时间范围筛选
- 事件类型多选筛选
- 分页切换
每条记录最少展示:
- 事件类型
- 事件标题
- 操作人
- 发生时间
- 变更摘要
- 原因/备注
其中“产品状态变更”需要明确体现:
- 变更前状态
- 变更后状态
- 变更原因
## 11. 为什么不建议继续让前端拼装
如果继续沿用当前前端拼装方案,会有这些问题:
- 产品状态变更前后值无法补齐
- 产品经理变更前后值无法补齐
- 无法支持分页
- 无法支持统一时间筛选
- 无法支持统一类型筛选
- 不同事件文案会在前端散落拼装,长期维护成本高
因此这里的前后端边界应明确为:
- 后端提供统一产品动态分页接口
- 前端负责筛选条件组织、分页交互和时间线展示
## 12. 本轮需求结论
本轮给后端的结论可以直接收敛为:
1. 当前前端已有接口不满足正式产品动态时间线需求
2. 后端新增 `GET /project/product/{id}/activities/page`
3. 接口必须支持分页、默认最近 `30 天`、自定义时间范围、事件类型多选
4. 第一版事件类型只做:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
5. 产品状态变更必须提供前后状态和原因
6. 产品经理变更必须提供前后经理信息
这条接口交付后,前端才能把当前“产品动态时间线”从拼装摘要升级成正式可查询模块。

View File

@@ -0,0 +1,292 @@
# 产品对象首页改版设计说明
日期2026-04-23
## 1. 目标
本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。
本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页:
- 第一眼先让用户知道当前看的是什么产品
- 第二眼能快速判断对象最近发生了什么
- 第三眼能看出需求池现在的经营状态和最近变化
- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口
## 2. 已确认诉求
基于本轮对话,已确认以下用户诉求:
1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片
2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息
3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态
4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息
5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域
6. 快捷入口不要保留
7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块
8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计
## 3. 首页定位结论
本页定位不是:
- 纯报表看板
- 纯审计日志页
- 设置页搬运版
- 导航入口集合页
本页定位应当是:
- 产品对象首页
- 偏统计,也带审计
- 但页面主语始终是“当前产品对象”
换句话说,这个页面要同时回答三个问题:
1. 我现在看的是什么产品?
2. 这个产品对象最近发生了什么?
3. 这个产品的需求池现在处于什么状态?
## 4. 页面结构
### 4.1 桌面端结构
桌面端建议采用三层结构:
1. 顶部 `对象基础概述横幅`
2. 中部 `左时间线 + 右需求池双模块`
3. 底部 `扩展信息区`
推荐布局比例:
- 顶部横幅:`24 / 24`
- 中部主区:左 `16 / 24`,右 `8 / 24`
- 底部扩展区:`24 / 24`
中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。
### 4.2 移动端结构
移动端统一退化为单列纵向布局,顺序为:
1. 对象基础概述横幅
2. 对象 / 团队动态时间线
3. 需求池管理概览
4. 需求池最近变化
5. 扩展信息区
移动端不强撑左右栏并排,不做卡片墙式压缩。
## 5. 模块设计
### 5.1 对象基础概述横幅
顶部采用“档案横幅型”,不采用纯指标卡片型。
横幅左侧承接对象身份信息:
- 产品名称
- 产品编号
- 当前状态标签
- 产品经理
- 团队规模
- 团队角色摘要
- 简短描述或备注
横幅右侧承接 4 个摘要指标:
- 团队人数
- 需求总量
- 待处理需求
- 最近动态时间
设计原则:
- 左侧负责建立对象识别
- 右侧负责快速判断当前概况
- 右侧指标只保留 4 项,不堆成报表卡片墙
### 5.2 对象 / 团队动态时间线
该区域位于中部左侧,是首页的主阅读区。
这条时间线只承接对象与团队变化,不承接需求事件。
第一版事件范围收敛为:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
- 成员角色调整
每条时间线建议展示:
- 事件标题
- 事件类型标签
- 发生时间
- 操作摘要
- 必要时展示原因或备注
表达目标是“业务时间线”,不是后台审计表格。
### 5.3 需求池管理概览
该区域位于中部右侧上半块,用于表达需求池的经营状态。
第一版首页需要优先看到的内容:
- 需求总量
- 各状态数量
- 待处理数量
- 高优先级待处理数量
展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。
这一块回答的是:
- 需求池是否健康
- 当前待处理压力大不大
- 是否存在需要优先关注的积压
### 5.4 需求池最近变化
该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。
该区域不重复展示总量,而是展示需求池最近发生的变化。
第一版建议承接:
- 最近新增需求
- 最近状态流转
- 最近关闭或完成
每条记录建议至少展示:
- 需求标题
- 动作类型
- 时间
- 当前状态或状态变更摘要
若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。
### 5.5 扩展信息区
底部不再保留快捷入口,改为正式扩展信息区。
当前优先预留 3 类模块位:
- 里程碑
- 风险点管理
- 产品资料
这一层的作用是:
- 为后续对象级信息继续扩展留下稳定挂载位
- 不把中部主结构挤成信息大杂烩
- 避免为了未来模块提前做假导航入口
如果当前没有稳定接口,可先保留正式卡片结构与空态说明。
## 6. 数据策略
### 6.1 真实接口优先
当前首页优先消费现有真实接口:
- `fetchGetProduct`
- `fetchGetProductSettings`
- `fetchGetProductMembers`
这些接口足以支撑:
- 对象基础概述中的名称、编号、状态、产品经理、描述
- 团队人数与角色摘要
- 最近动态中的产品创建、状态变化、成员加入/移出
### 6.2 假数据使用边界
当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确:
- 需求池管理概览
- 需求池最近变化
- 扩展信息区中的里程碑、风险点管理、产品资料摘要
假数据的使用原则:
1. 只补“当前没有稳定接口”的区域
2. 不反向污染对象基础信息
3. 不把假数据混入对象上下文 store
4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换
### 6.3 不推荐的做法
以下做法应避免:
- 把需求假数据散落写进页面组件
- 用对象 demo 数据冒充真实产品详情
- 把对象时间线和需求时间线混成一条
- 用快捷入口伪装成首页内容
## 7. 空态规则
首页至少要区分三种状态:
1. 能力未接入,只能先显示正式占位信息
2. 能力已接入,但当前该产品暂无业务数据
3. 当前用户无权限查看该模块
这三种状态不能共用一套模糊文案。
对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。
## 8. 页面边界
首页明确不承接以下内容:
- 快捷入口导航区
- 完整团队成员表格
- 完整需求列表表格
- 设置页重表单
- 完整审计日志明细页
首页要做的是概述、判断与阅读,不是重操作页。
## 9. 实施建议
第一阶段建议先完成结构性改造:
1. 重做顶部横幅,建立对象档案感
2. 保留中部左高右双块结构
3. 用真实接口接通对象概述与对象 / 团队时间线
4. 用局部 mock 数据先接通需求池两块和底部扩展区
第二阶段再逐步替换需求池与扩展区数据源:
- 接真实需求池统计接口
- 接真实需求动态接口
- 接里程碑、风险点、产品资料摘要接口
## 10. 验证标准
本设计是否成立,可按以下标准判断:
1. 进入首页后,第一眼能认出当前产品对象
2. 用户能自然读到对象 / 团队最近发生了什么
3. 右侧能快速判断需求池当前压力与最近变化
4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页”
5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位
6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构
## 11. 本轮设计结论
本轮最终设计结论如下:
- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页
- 顶部采用档案横幅型,先立住对象身份信息
- 中部左侧是高权重的对象 / 团队动态时间线
- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块
- 底部去掉快捷入口,改为正式扩展信息区
- 当前有真实接口的模块优先接真实接口
- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中

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_UPDATE, 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

@@ -1,8 +1,11 @@
package com.njcn.rdms.module.project.service.product;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
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;
@@ -11,7 +14,11 @@ 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.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import jakarta.annotation.Resource;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -19,21 +26,21 @@ 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 java.util.function.Function;
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;
private static final String ACTIVITY_ROLE_NAME_CACHE = "project_activity_role_name#10m";
private static final String ROLE_SCOPE_OBJECT = "object";
@Resource
private ProductStatusLogMapper productStatusLogMapper;
@@ -41,18 +48,22 @@ public class ProductActivityQueryService {
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private CacheManager cacheManager;
@Resource
private ObjectPermissionApi objectPermissionApi;
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);
}
@@ -63,7 +74,9 @@ public class ProductActivityQueryService {
List<ProductActivityRespVO> activities = items.stream()
.map(ActivityItem::respVO)
.toList();
return buildPageResult(activities, reqVO);
PageResult<ProductActivityRespVO> pageResult = buildPageResult(activities, reqVO);
fillMemberRoleNames(pageResult.getList());
return pageResult;
}
private void appendMemberActivities(Long productId, ProductActivityPageReqVO reqVO, List<ActivityItem> items) {
@@ -104,15 +117,140 @@ public class ProductActivityQueryService {
return new PageResult<>(activities.subList(start, end), (long) activities.size());
}
private void fillMemberRoleNames(List<ProductActivityRespVO> activities) {
if (activities == null || activities.isEmpty()) {
return;
}
Set<Long> roleIds = new LinkedHashSet<>();
for (ProductActivityRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
Long beforeRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "before");
Long afterRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "after");
if (beforeRoleId != null) {
roleIds.add(beforeRoleId);
}
if (afterRoleId != null) {
roleIds.add(afterRoleId);
}
}
if (roleIds.isEmpty()) {
return;
}
Map<Long, String> roleNameMap = loadRoleNameMap(roleIds);
for (ProductActivityRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
activity.setDetails(appendRoleNames(activity.getDetails(), roleNameMap));
}
}
private Map<Long, String> loadRoleNameMap(Set<Long> roleIds) {
Map<Long, String> roleNameMap = new LinkedHashMap<>();
if (roleIds == null || roleIds.isEmpty()) {
return roleNameMap;
}
Cache cache = cacheManager == null ? null : cacheManager.getCache(ACTIVITY_ROLE_NAME_CACHE);
Set<Long> missIds = new LinkedHashSet<>();
for (Long roleId : roleIds) {
String roleName = cache == null ? null : cache.get(roleId, String.class);
if (roleName != null) {
roleNameMap.put(roleId, roleName);
} else {
missIds.add(roleId);
}
}
if (missIds.isEmpty()) {
return roleNameMap;
}
Map<Long, ObjectRoleRespDTO> roleMap = objectPermissionApi == null ? Map.of()
: objectPermissionApi.getObjectRoleMap(missIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE);
if (roleMap == null || roleMap.isEmpty()) {
return roleNameMap;
}
for (Long roleId : missIds) {
ObjectRoleRespDTO role = roleMap.get(roleId);
if (role == null || !StringUtils.hasText(role.getName())) {
continue;
}
roleNameMap.put(roleId, role.getName());
if (cache != null) {
cache.put(roleId, role.getName());
}
}
return roleNameMap;
}
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 String appendRoleNames(String details, Map<Long, String> roleNameMap) {
if (!StringUtils.hasText(details) || !JsonUtils.isJsonObject(details)) {
return details;
}
Long beforeRoleId = getFieldChangeLong(details, "roleId", "before");
Long afterRoleId = getFieldChangeLong(details, "roleId", "after");
if (beforeRoleId == null && afterRoleId == null) {
return details;
}
JsonNode detailsNode = JsonUtils.parseTree(details);
if (!(detailsNode instanceof ObjectNode)) {
return details;
}
ObjectNode objectNode = ((ObjectNode) detailsNode).deepCopy();
ObjectNode roleNameNode = objectNode.putObject("roleName");
appendRoleName(roleNameNode, "before", beforeRoleId, roleNameMap);
appendRoleName(roleNameNode, "after", afterRoleId, roleNameMap);
return JsonUtils.toJsonString(objectNode);
}
private void appendRoleName(ObjectNode roleNameNode, String fieldName, Long roleId, Map<Long, String> roleNameMap) {
if (roleId == null) {
roleNameNode.putNull(fieldName);
return;
}
String roleName = roleNameMap.get(roleId);
if (StringUtils.hasText(roleName)) {
roleNameNode.put(fieldName, roleName);
return;
}
roleNameNode.putNull(fieldName);
}
private boolean includeType(String actual, String expected) {
return !StringUtils.hasText(actual) || Objects.equals(actual.trim(), expected);
}
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 +271,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 +287,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,522 @@
package com.njcn.rdms.module.project.service.product;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
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";
private static final String TIMELINE_ROLE_NAME_CACHE = "project_timeline_role_name#10m";
private static final String ROLE_SCOPE_OBJECT = "object";
private static final String PRODUCT_OBJECT_TYPE = ObjectActivityConstants.PRODUCT_BIZ_TYPE;
@Resource
private ProductStatusLogMapper productStatusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private CacheManager cacheManager;
@Resource
private AdminUserApi adminUserApi;
@Resource
private ObjectPermissionApi objectPermissionApi;
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());
fillMemberRoleNames(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 void fillMemberRoleNames(List<ProductActivityTimelineRespVO> activities) {
if (activities == null || activities.isEmpty()) {
return;
}
Set<Long> roleIds = new LinkedHashSet<>();
for (ProductActivityTimelineRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
Long beforeRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "before");
Long afterRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "after");
if (beforeRoleId != null) {
roleIds.add(beforeRoleId);
}
if (afterRoleId != null) {
roleIds.add(afterRoleId);
}
}
if (roleIds.isEmpty()) {
return;
}
Map<Long, String> roleNameMap = loadRoleNameMap(roleIds);
for (ProductActivityTimelineRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
activity.setDetails(appendRoleNames(activity.getDetails(), roleNameMap));
}
}
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 Map<Long, String> loadRoleNameMap(Set<Long> roleIds) {
Map<Long, String> roleNameMap = new LinkedHashMap<>();
if (roleIds == null || roleIds.isEmpty()) {
return roleNameMap;
}
Cache cache = cacheManager == null ? null : cacheManager.getCache(TIMELINE_ROLE_NAME_CACHE);
Set<Long> missIds = new LinkedHashSet<>();
for (Long roleId : roleIds) {
String roleName = cache == null ? null : cache.get(roleId, String.class);
if (roleName != null) {
roleNameMap.put(roleId, roleName);
} else {
missIds.add(roleId);
}
}
if (missIds.isEmpty()) {
return roleNameMap;
}
Map<Long, ObjectRoleRespDTO> roleMap = objectPermissionApi == null ? Map.of()
: objectPermissionApi.getObjectRoleMap(missIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE);
if (roleMap == null || roleMap.isEmpty()) {
return roleNameMap;
}
for (Long roleId : missIds) {
ObjectRoleRespDTO role = roleMap.get(roleId);
if (role == null || !StringUtils.hasText(role.getName())) {
continue;
}
roleNameMap.put(roleId, role.getName());
if (cache != null) {
cache.put(roleId, role.getName());
}
}
return roleNameMap;
}
private String appendRoleNames(String details, Map<Long, String> roleNameMap) {
if (!StringUtils.hasText(details) || !JsonUtils.isJsonObject(details)) {
return details;
}
Long beforeRoleId = getFieldChangeLong(details, "roleId", "before");
Long afterRoleId = getFieldChangeLong(details, "roleId", "after");
if (beforeRoleId == null && afterRoleId == null) {
return details;
}
JsonNode detailsNode = JsonUtils.parseTree(details);
if (!(detailsNode instanceof ObjectNode)) {
return details;
}
ObjectNode objectNode = ((ObjectNode) detailsNode).deepCopy();
ObjectNode roleNameNode = objectNode.putObject("roleName");
appendRoleName(roleNameNode, "before", beforeRoleId, roleNameMap);
appendRoleName(roleNameNode, "after", afterRoleId, roleNameMap);
return JsonUtils.toJsonString(objectNode);
}
private void appendRoleName(ObjectNode roleNameNode, String fieldName, Long roleId, Map<Long, String> roleNameMap) {
if (roleId == null) {
roleNameNode.putNull(fieldName);
return;
}
String roleName = roleNameMap.get(roleId);
if (StringUtils.hasText(roleName)) {
roleNameNode.put(fieldName, roleName);
return;
}
roleNameNode.putNull(fieldName);
}
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,15 @@ 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;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
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.product.*;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
@@ -58,13 +67,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";
@@ -106,7 +108,7 @@ public class ProductServiceImpl implements ProductService {
initManagerMemberRelation(product);
initDefaultRequirementModule(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();
}
@@ -217,8 +219,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
@@ -476,9 +478,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()));
@@ -489,7 +491,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()));
@@ -519,7 +521,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

@@ -1,6 +1,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.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
@@ -10,14 +11,23 @@ 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.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import org.junit.jupiter.api.Test;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
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 ProductActivityQueryServiceTest extends BaseMockitoUnitTest {
@@ -30,6 +40,12 @@ class ProductActivityQueryServiceTest extends BaseMockitoUnitTest {
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private CacheManager cacheManager;
@Mock
private Cache roleNameCache;
@Mock
private ObjectPermissionApi objectPermissionApi;
@Test
void getProductActivities_shouldMergeStatusProductAndMemberActivities() {
@@ -110,4 +126,88 @@ class ProductActivityQueryServiceTest extends BaseMockitoUnitTest {
assertEquals(0, result.getList().size());
}
@Test
void getProductActivities_shouldAppendRoleNameToMemberDetails() {
Long productId = 1003L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO memberAudit = new BizAuditLogDO();
memberAudit.setId(55L);
memberAudit.setBizType("rdms_user_object_role");
memberAudit.setBizId(9002L);
memberAudit.setActionType("update_member");
memberAudit.setFieldChanges("{\"roleId\":{\"before\":3201,\"after\":3202}}");
memberAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 13, 0, 0));
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9002L);
member.setObjectType("product");
member.setObjectId(productId);
when(bizAuditLogMapper.selectListByBizType("rdms_user_object_role", null, null))
.thenReturn(List.of(memberAudit));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9002L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_activity_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3201L, String.class)).thenReturn("产品经理");
when(roleNameCache.get(3202L, String.class)).thenReturn("产品成员");
PageResult<ProductActivityRespVO> result = productActivityQueryService.getProductActivities(productId, reqVO);
assertEquals("产品经理", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("before").asText());
assertEquals("产品成员", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("after").asText());
verify(objectPermissionApi, never()).getObjectRoleMap(anySet(), any(), any());
}
@Test
void getProductActivities_shouldLoadAndCacheRoleNameWhenCacheMiss() {
Long productId = 1004L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO memberAudit = new BizAuditLogDO();
memberAudit.setId(66L);
memberAudit.setBizType("rdms_user_object_role");
memberAudit.setBizId(9003L);
memberAudit.setActionType("add_member");
memberAudit.setFieldChanges("{\"roleId\":{\"before\":null,\"after\":3203}}");
memberAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 14, 0, 0));
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9003L);
member.setObjectType("product");
member.setObjectId(productId);
when(bizAuditLogMapper.selectListByBizType("rdms_user_object_role", null, null))
.thenReturn(List.of(memberAudit));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9003L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_activity_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3203L, String.class)).thenReturn(null);
when(objectPermissionApi.getObjectRoleMap(java.util.Set.of(3203L), "object", "product"))
.thenReturn(Map.of(3203L, buildRole(3203L, "观察者")));
PageResult<ProductActivityRespVO> result = productActivityQueryService.getProductActivities(productId, reqVO);
assertEquals("观察者", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("after").asText());
verify(roleNameCache).put(3203L, "观察者");
}
private ObjectRoleRespDTO buildRole(Long id, String name) {
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
role.setId(id);
role.setName(name);
role.setScopeType("object");
role.setObjectType("product");
return role;
}
}

View File

@@ -0,0 +1,424 @@
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.common.util.json.JsonUtils;
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.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
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 Cache roleNameCache;
@Mock
private AdminUserApi adminUserApi;
@Mock
private ObjectPermissionApi objectPermissionApi;
@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, "成员戊");
}
@Test
void getProductActivityTimelinePage_shouldReadRoleNameFromCacheFirst() {
Long productId = 1009L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO addMemberLog = buildMemberLog(81L, 9006L, "add_member",
"{\"roleId\":{\"before\":null,\"after\":3101},\"userId\":{\"before\":null,\"after\":2006}}",
LocalDateTime.of(2026, 4, 21, 19, 0, 0));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(addMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9006L), "product", productId))
.thenReturn(List.of(buildMember(9006L, productId, 2006L)));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(cacheManager.getCache("project_timeline_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3101L, String.class)).thenReturn("产品经理");
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("产品经理", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("after").asText());
verify(objectPermissionApi, never()).getObjectRoleMap(anySet(), any(), any());
}
@Test
void getProductActivityTimelinePage_shouldLoadAndCacheRoleNameWhenCacheMiss() {
Long productId = 1010L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO removeMemberLog = buildMemberLog(91L, 9007L, "remove_member",
"{\"roleId\":{\"before\":3102,\"after\":3102},\"userId\":{\"before\":2007,\"after\":2007}}",
LocalDateTime.of(2026, 4, 21, 20, 0, 0));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(removeMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9007L), "product", productId))
.thenReturn(List.of(buildMember(9007L, productId, 2007L)));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(cacheManager.getCache("project_timeline_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3102L, String.class)).thenReturn(null);
when(objectPermissionApi.getObjectRoleMap(java.util.Set.of(3102L), "object", "product"))
.thenReturn(Map.of(3102L, buildRole(3102L, "产品成员")));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("产品成员", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("before").asText());
verify(roleNameCache).put(3102L, "产品成员");
}
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;
}
private ObjectRoleRespDTO buildRole(Long id, String name) {
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
role.setId(id);
role.setName(name);
role.setScopeType("object");
role.setObjectType("product");
return role;
}
}

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