From ee732b97bf524be3a21edf9a7bf9870a4416d8e5 Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Fri, 24 Apr 2026 15:43:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(project):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E5=8A=A8=E6=80=81=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B9=B6=E9=87=8D=E6=9E=84=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /project/product/{id}/activities/page 接口用于产品动态时间线分页查询 - 添加 ProductActivityTimelinePageReqVO 和 ProductActivityTimelineRespVO 数据传输对象 - 实现 ProductActivityTimelineQueryService 服务处理动态时间线查询逻辑 - 在 BizAuditLogMapper 中新增按业务类型和动作类型查询的方法 - 在 ProductStatusLogMapper 中新增按产品ID和动作类型查询的方法 - 将硬编码的活动类型常量抽取到 ObjectActivityConstants 统一管理 - 重构 ProductActivityQueryService 使用统一的常量和查询方法 - 更新 ProductMemberServiceImpl 和 ProductServiceImpl 使用新的活动常量 - 添加相应的单元测试验证新接口和查询逻辑的正确性 - 新增产品对象首页改版设计文档和产品动态时间线接口需求说明文档 --- ...23-product-activity-timeline-api-design.md | 270 ++++++++++++ ...-04-23-product-overview-homepage-design.md | 292 ++++++++++++ .../constant/ObjectActivityConstants.java | 86 ++++ .../product/ProductSettingController.java | 10 + .../ProductActivityTimelinePageReqVO.java | 35 ++ .../ProductActivityTimelineRespVO.java | 54 +++ .../dal/mysql/audit/BizAuditLogMapper.java | 21 + .../mysql/product/ProductStatusLogMapper.java | 10 + .../product/ProductActivityQueryService.java | 45 +- .../ProductActivityTimelineQueryService.java | 414 ++++++++++++++++++ .../product/ProductMemberServiceImpl.java | 27 +- .../service/product/ProductServiceImpl.java | 22 +- .../product/ProductSettingService.java | 12 + .../product/ProductSettingServiceImpl.java | 13 + ...oductActivityTimelineQueryServiceTest.java | 350 +++++++++++++++ .../ProductSettingServiceImplTest.java | 43 ++ 16 files changed, 1640 insertions(+), 64 deletions(-) create mode 100644 rdms-project/2026-04-23-product-activity-timeline-api-design.md create mode 100644 rdms-project/2026-04-23-product-overview-homepage-design.md create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelinePageReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelineRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java diff --git a/rdms-project/2026-04-23-product-activity-timeline-api-design.md b/rdms-project/2026-04-23-product-activity-timeline-api-design.md new file mode 100644 index 0000000..14a6631 --- /dev/null +++ b/rdms-project/2026-04-23-product-activity-timeline-api-design.md @@ -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. 产品经理变更必须提供前后经理信息 + +这条接口交付后,前端才能把当前“产品动态时间线”从拼装摘要升级成正式可查询模块。 diff --git a/rdms-project/2026-04-23-product-overview-homepage-design.md b/rdms-project/2026-04-23-product-overview-homepage-design.md new file mode 100644 index 0000000..58da0c6 --- /dev/null +++ b/rdms-project/2026-04-23-product-overview-homepage-design.md @@ -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 数据源中 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java new file mode 100644 index 0000000..aee265a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java @@ -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 STATUS_ACTION_TYPES = List.of( + STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON); + + public static final List PRODUCT_TIMELINE_ACTION_TYPES = List.of( + PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER); + + public static final List MEMBER_TIMELINE_ACTION_TYPES = List.of( + MEMBER_ACTION_ADD, MEMBER_ACTION_REMOVE); + + private static final Set 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; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java index 8ef29f9..5ae9304 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java @@ -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> 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") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelinePageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelinePageReqVO.java new file mode 100644 index 0000000..34205b3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelinePageReqVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelineRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelineRespVO.java new file mode 100644 index 0000000..179fe77 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityTimelineRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java index d879001..c40583c 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java @@ -22,6 +22,17 @@ public interface BizAuditLogMapper extends BaseMapperX { .orderByDesc(BizAuditLogDO::getId)); } + default List selectListByBizAndActions(String bizType, Long bizId, List actionTypes, + LocalDateTime startTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .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 selectListByBizType(String bizType, String actionType, LocalDateTime[] operateTime) { return selectList(new LambdaQueryWrapperX() .eq(BizAuditLogDO::getBizType, bizType) @@ -31,4 +42,14 @@ public interface BizAuditLogMapper extends BaseMapperX { .orderByDesc(BizAuditLogDO::getId)); } + default List selectListByBizTypeAndActions(String bizType, List actionTypes, + LocalDateTime startTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .eq(BizAuditLogDO::getBizType, bizType) + .inIfPresent(BizAuditLogDO::getActionType, actionTypes) + .betweenIfPresent(BaseDO::getCreateTime, startTime, endTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(BizAuditLogDO::getId)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java index ffb9fee..e518963 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java @@ -21,4 +21,14 @@ public interface ProductStatusLogMapper extends BaseMapperX .orderByDesc(ProductStatusLogDO::getId)); } + default List selectListByProductIdAndActions(Long productId, List actionTypes, + LocalDateTime startTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .eq(ProductStatusLogDO::getProductId, productId) + .inIfPresent(ProductStatusLogDO::getActionType, actionTypes) + .betweenIfPresent(BaseDO::getCreateTime, startTime, endTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(ProductStatusLogDO::getId)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java index 60e45d4..748468d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java @@ -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 getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { List 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)) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java new file mode 100644 index 0000000..4bf3a82 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java @@ -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 getProductActivityTimelinePage( + Long productId, ProductActivityTimelinePageReqVO reqVO) { + LocalDateTime[] timeRange = buildTimeRange(reqVO); + List actionTypes = normalizeActionTypes(reqVO.getActionTypes()); + List 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 pageResult = + buildPageResult(items.stream().map(ActivityItem::respVO).toList(), reqVO); + fillTargetUserNames(pageResult.getList()); + return pageResult; + } + + private void appendStatusActivities(Long productId, String activityType, List actionTypes, + LocalDateTime[] timeRange, List items) { + if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_STATUS)) { + return; + } + List 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 actionTypes, + LocalDateTime[] timeRange, List items) { + if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT)) { + return; + } + List productActions = limitActions(actionTypes, ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES); + if (shouldSkipByIntersection(actionTypes, productActions)) { + return; + } + List productLogs = bizAuditLogMapper.selectListByBizAndActions( + ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId, productActions, timeRange[0], timeRange[1]); + Set 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 actionTypes, + LocalDateTime[] timeRange, List items) { + if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) { + return; + } + List memberActions = limitActions(actionTypes, ObjectActivityConstants.MEMBER_TIMELINE_ACTION_TYPES); + if (shouldSkipByIntersection(actionTypes, memberActions)) { + return; + } + List memberLogs = bizAuditLogMapper.selectListByBizTypeAndActions( + ObjectActivityConstants.MEMBER_BIZ_TYPE, memberActions, timeRange[0], timeRange[1]); + if (memberLogs.isEmpty()) { + return; + } + Map memberMap = loadMemberMap(productId, memberLogs); + Set 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 normalizeActionTypes(List actionTypes) { + if (actionTypes == null || actionTypes.isEmpty()) { + return null; + } + List normalized = actionTypes.stream() + .filter(StringUtils::hasText) + .map(String::trim) + .distinct() + .toList(); + return normalized.isEmpty() ? null : normalized; + } + + private List limitActions(List actionTypes, List allowedActions) { + if (actionTypes == null || actionTypes.isEmpty()) { + return allowedActions; + } + return actionTypes.stream() + .filter(allowedActions::contains) + .distinct() + .toList(); + } + + private boolean shouldSkipByIntersection(List actualActions, List 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 loadMemberMap(Long productId, List memberLogs) { + List memberIds = memberLogs.stream() + .map(BizAuditLogDO::getBizId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (memberIds.isEmpty()) { + return Map.of(); + } + Map memberMap = new LinkedHashMap<>(); + for (UserObjectRoleDO member : userObjectRoleMapper.selectListByIdsAndObject( + memberIds, ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId)) { + memberMap.put(member.getId(), member); + } + return memberMap; + } + + private Set loadCreateSignatures(Long productId, LocalDateTime[] timeRange) { + List productLogs = bizAuditLogMapper.selectListByBizAndActions( + ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId, + ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES, timeRange[0], timeRange[1]); + return buildCreateSignatures(productLogs); + } + + private Set buildCreateSignatures(List productLogs) { + Set 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 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 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 buildPageResult(List 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 activities) { + if (activities == null || activities.isEmpty()) { + return; + } + Set userIds = new LinkedHashSet<>(); + for (ProductActivityTimelineRespVO activity : activities) { + if (activity != null && activity.getTargetUserId() != null) { + userIds.add(activity.getTargetUserId()); + } + } + if (userIds.isEmpty()) { + return; + } + Map nicknameMap = loadUserNicknameMap(userIds); + for (ProductActivityTimelineRespVO activity : activities) { + if (activity == null || activity.getTargetUserId() == null) { + continue; + } + activity.setTargetUserName(nicknameMap.get(activity.getTargetUserId())); + } + } + + private Map loadUserNicknameMap(Set userIds) { + Map nicknameMap = new LinkedHashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return nicknameMap; + } + Cache cache = cacheManager == null ? null : cacheManager.getCache(TIMELINE_USER_NICKNAME_CACHE); + Set 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 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 buildStatusDetails(ProductStatusLogDO log) { + Map 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) { + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java index 987ea14..7473980 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java @@ -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()); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java index 04675a2..58b5b50 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java @@ -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); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java index 56cb538..4392789 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java @@ -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 getProductActivities(Long productId, ProductActivityPageReqVO reqVO); + /** + * 获取产品动态时间线分页 + * + * @param productId 产品编号 + * @param reqVO 查询参数 + * @return 产品动态时间线分页 + */ + PageResult getProductActivityTimelinePage( + Long productId, ProductActivityTimelinePageReqVO reqVO); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java index 86eb372..5324cb3 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java @@ -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 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); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java new file mode 100644 index 0000000..fb969a2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java @@ -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 result = + productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO); + + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor 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 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 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 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 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 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 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 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; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java index f40b6e6..1999352 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java @@ -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 pageResult = new PageResult<>(List.of(respVO), 1L); + + when(productMapper.selectById(productId)).thenReturn(product); + when(productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO)) + .thenReturn(pageResult); + + PageResult 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()); + } + } From ae90dcec24ba74256958efe5740d3a0df99589ac Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Fri, 24 Apr 2026 16:22:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(project):=20=E4=B8=BA=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=B4=BB=E5=8A=A8=E6=97=B6=E9=97=B4=E7=BA=BF=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=88=90=E5=91=98=E8=A7=92=E8=89=B2=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ObjectActivityConstants 中添加 MEMBER_ACTION_UPDATE 类型支持 - 为 ProductActivityQueryService 和 ProductActivityTimelineQueryService 添加角色名称加载和缓存功能 - 实现角色名称解析和 JSON 数据结构扩展 - 添加相关单元测试验证角色名称显示逻辑 - 集成 ObjectPermissionApi 获取角色信息并实现缓存机制 --- .../constant/ObjectActivityConstants.java | 2 +- .../product/ProductActivityQueryService.java | 143 +++++++++++++++++- .../ProductActivityTimelineQueryService.java | 108 +++++++++++++ .../ProductActivityQueryServiceTest.java | 100 ++++++++++++ ...oductActivityTimelineQueryServiceTest.java | 74 +++++++++ 5 files changed, 425 insertions(+), 2 deletions(-) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java index aee265a..930e09e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java @@ -50,7 +50,7 @@ public final class ObjectActivityConstants { PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER); public static final List MEMBER_TIMELINE_ACTION_TYPES = List.of( - MEMBER_ACTION_ADD, MEMBER_ACTION_REMOVE); + MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE); private static final Set STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java index 748468d..2e42f91 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java @@ -1,5 +1,7 @@ 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; @@ -12,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; @@ -20,9 +26,11 @@ 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; @@ -31,6 +39,8 @@ public class ProductActivityQueryService { 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; @@ -38,6 +48,10 @@ public class ProductActivityQueryService { private BizAuditLogMapper bizAuditLogMapper; @Resource private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private CacheManager cacheManager; + @Resource + private ObjectPermissionApi objectPermissionApi; public PageResult getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { List items = new ArrayList<>(); @@ -60,7 +74,9 @@ public class ProductActivityQueryService { List activities = items.stream() .map(ActivityItem::respVO) .toList(); - return buildPageResult(activities, reqVO); + PageResult pageResult = buildPageResult(activities, reqVO); + fillMemberRoleNames(pageResult.getList()); + return pageResult; } private void appendMemberActivities(Long productId, ProductActivityPageReqVO reqVO, List items) { @@ -101,6 +117,131 @@ public class ProductActivityQueryService { return new PageResult<>(activities.subList(start, end), (long) activities.size()); } + private void fillMemberRoleNames(List activities) { + if (activities == null || activities.isEmpty()) { + return; + } + Set 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 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 loadRoleNameMap(Set roleIds) { + Map roleNameMap = new LinkedHashMap<>(); + if (roleIds == null || roleIds.isEmpty()) { + return roleNameMap; + } + Cache cache = cacheManager == null ? null : cacheManager.getCache(ACTIVITY_ROLE_NAME_CACHE); + Set 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 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 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 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); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java index 4bf3a82..2119171 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java @@ -1,6 +1,7 @@ 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; @@ -13,6 +14,8 @@ 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; @@ -40,6 +43,9 @@ 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; @@ -51,6 +57,8 @@ public class ProductActivityTimelineQueryService { private CacheManager cacheManager; @Resource private AdminUserApi adminUserApi; + @Resource + private ObjectPermissionApi objectPermissionApi; public PageResult getProductActivityTimelinePage( Long productId, ProductActivityTimelinePageReqVO reqVO) { @@ -68,6 +76,7 @@ public class ProductActivityTimelineQueryService { PageResult pageResult = buildPageResult(items.stream().map(ActivityItem::respVO).toList(), reqVO); fillTargetUserNames(pageResult.getList()); + fillMemberRoleNames(pageResult.getList()); return pageResult; } @@ -309,6 +318,36 @@ public class ProductActivityTimelineQueryService { } } + private void fillMemberRoleNames(List activities) { + if (activities == null || activities.isEmpty()) { + return; + } + Set 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 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 loadUserNicknameMap(Set userIds) { Map nicknameMap = new LinkedHashMap<>(); if (userIds == null || userIds.isEmpty()) { @@ -344,6 +383,75 @@ public class ProductActivityTimelineQueryService { return nicknameMap; } + private Map loadRoleNameMap(Set roleIds) { + Map roleNameMap = new LinkedHashMap<>(); + if (roleIds == null || roleIds.isEmpty()) { + return roleNameMap; + } + Cache cache = cacheManager == null ? null : cacheManager.getCache(TIMELINE_ROLE_NAME_CACHE); + Set 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 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 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 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()); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java index 5c5f2e3..3b189cc 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java @@ -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 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 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; + } + } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java index fb969a2..acac506 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java @@ -2,6 +2,7 @@ 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; @@ -11,6 +12,8 @@ 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; @@ -51,7 +54,11 @@ class ProductActivityTimelineQueryServiceTest extends BaseMockitoUnitTest { @Mock private Cache userNicknameCache; @Mock + private Cache roleNameCache; + @Mock private AdminUserApi adminUserApi; + @Mock + private ObjectPermissionApi objectPermissionApi; @Test void getProductActivityTimelinePage_whenTimeRangeMissingEnd_shouldThrowInvalidParam() { @@ -289,6 +296,64 @@ class ProductActivityTimelineQueryServiceTest extends BaseMockitoUnitTest { 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 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 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(); @@ -347,4 +412,13 @@ class ProductActivityTimelineQueryServiceTest extends BaseMockitoUnitTest { 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; + } + }