refactor(project): 重构权限常量定义并移除需求进度聚合功能
- 将产品和项目查询权限码统一提取到常量类中 - 移除需求进度聚合计算的相关实现代码 - 更新权限验证注解使用新的常量定义 - 清理相关的单元测试代码 - 更新错误码注释说明
This commit is contained in:
@@ -84,6 +84,15 @@
|
||||
|
||||
判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。
|
||||
|
||||
### 站内信通知事件(project 域统一入口)
|
||||
|
||||
业务动作要发站内信时,统一走 `framework/notify` 的 `NotifySendEvent`(`publishEvent(NotifySendEvent.of(userIds, templateCode, params))`),由全局唯一的 `NotifySendEventListener`(`@TransactionalEventListener(AFTER_COMMIT)`)做去重 / 兜底 / 调 `NotifyMessageSendApi`;不要绕过事件直接在业务 Service 里注入发送 API。
|
||||
|
||||
红线:
|
||||
|
||||
- **`NotifySendEvent` 只能在 `@Transactional` 的业务方法里 publish**。`@TransactionalEventListener` 的语义是"只在有事务的上下文里 publish 才会被消费"——在无事务方法里 publish,通知会**静默不发、不报错**。新业务接入通知前先确认触发点方法有事务;确实无事务的场景不要"顺手 publish",要么补事务,要么找用户确认方案。
|
||||
- 模板场景码登记进 `NotifyTemplateCodeConstants`,并保证 `system_notify_template` 已配同 code 模板(模板缺失时监听器只 `log.warn`,该场景通知发不出但不影响业务)。
|
||||
|
||||
## 接口语义(HTTP 动词)
|
||||
|
||||
本仓库 update 类接口默认按 RESTful 标准用 HTTP 动词区分语义,前后端必须按下表对齐,避免"前端没传字段"和"前端想清空"在后端无法区分的歧义。
|
||||
|
||||
@@ -35,6 +35,19 @@ public final class ProjectObjectConstants {
|
||||
*/
|
||||
public static final String STATUS_CANCELLED = "cancelled";
|
||||
|
||||
/**
|
||||
* 已归档项目状态编码。
|
||||
*/
|
||||
public static final String STATUS_ARCHIVED = "archived";
|
||||
|
||||
/**
|
||||
* "全部"口径的排除锚点:列表/统计缺省不含 已取消、已归档。
|
||||
* 状态全集以 rdms_object_status_model 为权威源运行时推导(DB 新增状态自动进入"全部"口径),
|
||||
* 代码只锚定排除项,与主线唯一性校验的 STATUS_CANCELLED 同属最小硬编码。
|
||||
* 设计来源:docs/superpowers/specs/2026-06-10-项目列表产品分组-design.md 第二节。
|
||||
*/
|
||||
public static final Set<String> DEFAULT_QUERY_EXCLUDED_STATUS_CODES = Set.of(STATUS_CANCELLED, STATUS_ARCHIVED);
|
||||
|
||||
/**
|
||||
* 项目自动编码前缀。
|
||||
*/
|
||||
|
||||
@@ -2,16 +2,16 @@ package com.njcn.rdms.module.project.controller.admin.project;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -73,8 +73,14 @@ public class ProjectController {
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获取项目分页")
|
||||
public CommonResult<PageResult<ProjectRespVO>> getProjectPage(@Valid ProjectPageReqVO pageReqVO) {
|
||||
PageResult<ProjectDO> pageResult = projectService.getProjectPage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, ProjectRespVO.class));
|
||||
return success(projectService.getProjectPage(pageReqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/group-page")
|
||||
@Operation(summary = "获取项目按产品分组分页")
|
||||
public CommonResult<ProjectGroupPageRespVO> getProjectGroupPage(@Valid ProjectGroupPageReqVO reqVO) {
|
||||
// 与 page / overview-summary 一致:全域读路径不挂权限注解,可见性由 Service 内 ObjectDataScope 数据权限承载
|
||||
return success(projectService.getProjectGroupPage(reqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/list-by-product")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Schema(description = "管理后台 - 项目按产品分组分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ProjectGroupPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "关键词,匹配项目编码或项目名称", example = "CNPJ2026001")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "项目类型字典值", example = "main")
|
||||
@Size(max = 32, message = "项目类型长度不能超过32个字符")
|
||||
private String projectType;
|
||||
|
||||
@Schema(description = "所属产品编号", example = "1001")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "项目状态编码;缺省 = 「全部」口径(状态机启用状态,不含已取消/已归档)", example = "active")
|
||||
@Size(max = 32, message = "项目状态编码长度不能超过32个字符")
|
||||
private String statusCode;
|
||||
|
||||
@Schema(description = "仅返回游离组(未挂产品的项目),不可与 productId 同时使用", example = "true")
|
||||
private Boolean orphanOnly;
|
||||
|
||||
@Schema(description = "每组返回项目条数上限,默认 5", example = "5")
|
||||
@Min(value = 1, message = "每组条数最小值为 1")
|
||||
@Max(value = 50, message = "每组条数最大值为 50")
|
||||
private Integer topN = 5;
|
||||
|
||||
@AssertTrue(message = "游离组筛选与产品筛选不能同时使用")
|
||||
@Schema(hidden = true)
|
||||
public boolean isOrphanFilterValid() {
|
||||
return !Boolean.TRUE.equals(orphanOnly) || productId == null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 项目按产品分组分页 Response VO")
|
||||
@Data
|
||||
public class ProjectGroupPageRespVO {
|
||||
|
||||
@Schema(description = "产品组总数(分页 total,含游离组)")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "当前筛选口径下项目总数(含游离项目)")
|
||||
private Long projectTotal;
|
||||
|
||||
@Schema(description = "当前用户可见、启用/暂停状态产品去重后的方向数")
|
||||
private Integer directionCount;
|
||||
|
||||
@Schema(description = "当前筛选口径下游离项目数")
|
||||
private Long orphanTotal;
|
||||
|
||||
@Schema(description = "产品组列表:同方向相邻,游离组固定最后")
|
||||
private List<ProjectGroupRespVO> list;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Schema(description = "管理后台 - 项目产品分组 Response VO")
|
||||
@Data
|
||||
public class ProjectGroupRespVO {
|
||||
|
||||
@Schema(description = "产品编号,游离组为 null", example = "1001")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "产品名称,游离组固定为「游离项目」", example = "智能网关")
|
||||
private String productName;
|
||||
|
||||
@Schema(description = "产品编码,游离组为 null", example = "CNPD2026001")
|
||||
private String productCode;
|
||||
|
||||
@Schema(description = "产品方向字典值,游离组为空串", example = "platform")
|
||||
private String directionCode;
|
||||
|
||||
@Schema(description = "产品负责人用户编号,游离组为 null", example = "1024")
|
||||
private Long managerUserId;
|
||||
|
||||
@Schema(description = "产品负责人昵称,游离组为 null", example = "张三")
|
||||
private String managerUserNickname;
|
||||
|
||||
@Schema(description = "当前筛选口径下该组项目总数")
|
||||
private Long projectTotal;
|
||||
|
||||
@Schema(description = "组内前 topN 条项目(更新时间倒序),字段与项目分页接口行一致")
|
||||
private List<ProjectRespVO> projects;
|
||||
|
||||
@Schema(description = "项目类型计数,恒按「全部」口径(不含已取消/已归档)统计,不随状态筛选变化")
|
||||
private Map<String, Long> typeCounts;
|
||||
|
||||
@Schema(description = "是否存在非已取消的主线(基线)项目,口径与创建唯一性校验一致")
|
||||
private Boolean hasBaseline;
|
||||
|
||||
@Schema(description = "是否游离组")
|
||||
private Boolean orphan;
|
||||
|
||||
}
|
||||
@@ -12,4 +12,7 @@ public class ProjectOverviewSummaryRespVO {
|
||||
@Schema(description = "项目状态数量,按当前启用的项目状态模型返回")
|
||||
private Map<String, Long> statusCounts;
|
||||
|
||||
@Schema(description = "游离项目数:未挂产品且状态属于「全部」口径(状态机启用状态,不含已取消/已归档)")
|
||||
private Long orphanCount;
|
||||
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import com.njcn.rdms.framework.common.pojo.PageParam;
|
||||
import com.njcn.rdms.framework.dict.validation.InDict;
|
||||
import com.njcn.rdms.module.system.enums.DictTypeConstants;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
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;
|
||||
|
||||
@@ -43,4 +45,16 @@ public class ProjectPageReqVO extends PageParam {
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime[] updateTime;
|
||||
|
||||
@Schema(description = "仅查询游离项目(未挂产品),不可与 productId 同时使用", example = "true")
|
||||
private Boolean orphanOnly;
|
||||
|
||||
@Schema(description = "项目状态编码集合,存在时优先于 statusCode 生效", example = "[\"pending\", \"active\"]")
|
||||
private List<String> statusCodes;
|
||||
|
||||
@AssertTrue(message = "游离项目筛选与产品筛选不能同时使用")
|
||||
@Schema(hidden = true)
|
||||
public boolean isOrphanFilterValid() {
|
||||
return !Boolean.TRUE.equals(orphanOnly) || productId == null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通用站内信发送事件(project 域统一入口)。
|
||||
*
|
||||
* <p>业务方在动作完成后 {@code publishEvent(NotifySendEvent.of(...))},
|
||||
* 由 {@link NotifySendEventListener} 在事务提交后统一发送。
|
||||
* 是否包含操作人自己,由业务方组织 userIds 时决定(统一入口不排除操作人)。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class NotifySendEvent {
|
||||
|
||||
/** 接收人用户编号(可含重复,监听器负责去重) */
|
||||
private final Collection<Long> userIds;
|
||||
/** 模板场景码,见 {@link NotifyTemplateCodeConstants} */
|
||||
private final String templateCode;
|
||||
/** 模板参数 */
|
||||
private final Map<String, Object> params;
|
||||
|
||||
private NotifySendEvent(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
||||
this.userIds = userIds;
|
||||
this.templateCode = templateCode;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
public static NotifySendEvent of(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
||||
return new NotifySendEvent(userIds, templateCode, params);
|
||||
}
|
||||
|
||||
public Collection<Long> getUserIds() {
|
||||
return userIds;
|
||||
}
|
||||
|
||||
public String getTemplateCode() {
|
||||
return templateCode;
|
||||
}
|
||||
|
||||
public Map<String, Object> getParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 站内信发送事件监听器(project 域统一入口,全局唯一)。
|
||||
*
|
||||
* <p>在业务事务提交后(AFTER_COMMIT)统一发送:接收人去重 + 空短路 + 逐人调用,
|
||||
* 单条失败 try-catch + log.warn,不抛出、不中断其余,绝不影响/回滚业务。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class NotifySendEventListener {
|
||||
|
||||
@Resource
|
||||
private NotifyMessageSendApi notifyMessageSendApi;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void onNotifySend(NotifySendEvent event) {
|
||||
if (event == null || event.getUserIds() == null) {
|
||||
return;
|
||||
}
|
||||
// 去重(保留首次出现顺序),过滤 null
|
||||
Set<Long> targets = new LinkedHashSet<>();
|
||||
for (Long userId : event.getUserIds()) {
|
||||
if (Objects.nonNull(userId)) {
|
||||
targets.add(userId);
|
||||
}
|
||||
}
|
||||
if (targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Long userId : targets) {
|
||||
try {
|
||||
notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(), event.getParams());
|
||||
} catch (Exception ex) {
|
||||
// 通知失败不影响业务:仅告警,继续发其余接收人
|
||||
log.warn("[onNotifySend] 站内信发送失败 userId={}, templateCode={}",
|
||||
userId, event.getTemplateCode(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
/**
|
||||
* 站内信模板场景码常量。
|
||||
*
|
||||
* <p>每个 code 对应 {@code system_notify_template} 一条模板(发送前模板必须已配置,
|
||||
* 否则能力层抛错、被 {@link NotifySendEventListener} 兜底为 log.warn)。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class NotifyTemplateCodeConstants {
|
||||
|
||||
/** 任务指派:创建任务后通知负责人 + 协办人 */
|
||||
public static final String TASK_ASSIGNED = "task_assigned";
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.framework.rpc.config;
|
||||
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.file.FileApi;
|
||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.PermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
|
||||
@@ -14,6 +15,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
* Project 模块的 RPC 配置
|
||||
*/
|
||||
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class})
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class, NotifyMessageSendApi.class})
|
||||
public class RpcConfiguration {
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
@@ -50,7 +52,13 @@ public interface ProjectService {
|
||||
|
||||
ProjectContextRespVO getProjectContext(Long id);
|
||||
|
||||
PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO);
|
||||
PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 项目按产品分组分页:组维度翻页,组内带 topN 条项目。
|
||||
* 口径见 docs/superpowers/specs/2026-06-10-项目列表产品分组-design.md 第三节。
|
||||
*/
|
||||
ProjectGroupPageRespVO getProjectGroupPage(ProjectGroupPageReqVO reqVO);
|
||||
|
||||
ProjectOverviewSummaryRespVO getProjectOverviewSummary();
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectC
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRoleRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
@@ -68,6 +71,7 @@ import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
@@ -454,7 +458,7 @@ class ProjectServiceImpl implements ProjectService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO) {
|
||||
public PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO) {
|
||||
// 计算当前用户在 project 域的数据权限范围
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||
@@ -473,31 +477,24 @@ class ProjectServiceImpl implements ProjectService {
|
||||
.eqIfPresent(ProjectDO::getDirectionCode, pageReqVO.getDirectionCode())
|
||||
.eqIfPresent(ProjectDO::getProductId, pageReqVO.getProductId())
|
||||
.eqIfPresent(ProjectDO::getManagerUserId, pageReqVO.getManagerUserId())
|
||||
.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode())
|
||||
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
|
||||
.orderByDesc(BaseDO::getCreateTime);
|
||||
|
||||
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
|
||||
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
|
||||
Set<Long> ids = scope.getIds();
|
||||
Set<String> dcs = scope.getDirectionCodes();
|
||||
wrapper.and(w -> {
|
||||
boolean addedAny = false;
|
||||
if (!ids.isEmpty()) {
|
||||
w.in(ProjectDO::getId, ids);
|
||||
addedAny = true;
|
||||
}
|
||||
if (!dcs.isEmpty()) {
|
||||
if (addedAny) {
|
||||
w.or();
|
||||
}
|
||||
w.in(ProjectDO::getDirectionCode, dcs);
|
||||
}
|
||||
});
|
||||
// 列表按更新时间倒序,与分组接口组内排序一致(2026-06-10 由创建时间倒序调整)
|
||||
.orderByDesc(BaseDO::getUpdateTime);
|
||||
// 状态筛选:多值集合优先于单值
|
||||
if (pageReqVO.getStatusCodes() != null && !pageReqVO.getStatusCodes().isEmpty()) {
|
||||
wrapper.in(ProjectDO::getStatusCode, pageReqVO.getStatusCodes());
|
||||
} else {
|
||||
wrapper.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode());
|
||||
}
|
||||
// ALL 状态不加任何 scope 条件,直接查全部
|
||||
// 游离项目筛选:仅未挂产品
|
||||
if (Boolean.TRUE.equals(pageReqVO.getOrphanOnly())) {
|
||||
wrapper.isNull(ProjectDO::getProductId);
|
||||
}
|
||||
// 注入 scope 数据权限过滤条件(在所有业务条件之后;ALL 状态不加条件)
|
||||
applyProjectScopeCondition(wrapper, scope);
|
||||
|
||||
return projectMapper.selectPage(pageReqVO, wrapper);
|
||||
PageResult<ProjectDO> pageResult = projectMapper.selectPage(pageReqVO, wrapper);
|
||||
return new PageResult<>(convertProjectListWithNames(pageResult.getList()), pageResult.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -510,9 +507,326 @@ class ProjectServiceImpl implements ProjectService {
|
||||
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
|
||||
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
|
||||
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
|
||||
respVO.setOrphanCount(countOrphanProjects(scope));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游离项目计数:未挂产品 + "全部"口径(与项目分组列表对齐)。
|
||||
*/
|
||||
private Long countOrphanProjects(ObjectDataScope scope) {
|
||||
if (scope.getState() == ObjectDataScope.State.EMPTY) {
|
||||
return 0L;
|
||||
}
|
||||
Set<String> statusCodes = resolveDefaultProjectStatusCodes();
|
||||
if (statusCodes.isEmpty()) {
|
||||
return 0L;
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||
wrapper.isNull(ProjectDO::getProductId)
|
||||
.in(ProjectDO::getStatusCode, statusCodes);
|
||||
applyProjectScopeCondition(wrapper, scope);
|
||||
return projectMapper.selectCount(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目"全部"口径状态集:状态机启用状态全集 - {已取消, 已归档}。
|
||||
* 权威源 rdms_object_status_model,DB 新增状态自动纳入,代码不维护正向清单。
|
||||
*/
|
||||
private Set<String> resolveDefaultProjectStatusCodes() {
|
||||
return objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE).stream()
|
||||
.map(ObjectStatusModelDO::getStatusCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.filter(code -> !ProjectObjectConstants.DEFAULT_QUERY_EXCLUDED_STATUS_CODES.contains(code))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品"存续"状态集:状态机启用且非终态的状态(当前为 启用/暂停),
|
||||
* 用于零项目占位组与方向数口径。权威源 rdms_object_status_model,零硬编码。
|
||||
*/
|
||||
private Set<String> resolveLiveProductStatusCodes() {
|
||||
return objectStatusModelMapper.selectListByObjectTypeEnabled(ProductObjectConstants.OBJECT_TYPE).stream()
|
||||
.filter(model -> !Boolean.TRUE.equals(model.getTerminalFlag()))
|
||||
.map(ObjectStatusModelDO::getStatusCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectGroupPageRespVO getProjectGroupPage(ProjectGroupPageReqVO reqVO) {
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
ObjectDataScope projectScope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||
ObjectDataScope productScope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||
|
||||
// 状态口径:"全部"集合由状态机推导(typeCounts 恒按该口径,无论是否显式传状态都需要)
|
||||
Set<String> defaultStatusCodes = resolveDefaultProjectStatusCodes();
|
||||
Collection<String> statusCodes = StringUtils.hasText(reqVO.getStatusCode())
|
||||
? List.of(reqVO.getStatusCode())
|
||||
: defaultStatusCodes;
|
||||
|
||||
// 1. 命中项目全量加载(产品几十级、项目百级,内存分组成本可忽略;项目上千后再改 SQL 聚合分页)
|
||||
List<ProjectDO> matched = selectGroupMatchedProjects(reqVO, statusCodes, projectScope);
|
||||
|
||||
// 2. 按产品分组,productId 为空归入游离组
|
||||
Map<Long, List<ProjectDO>> groupedByProduct = new LinkedHashMap<>();
|
||||
for (ProjectDO project : matched) {
|
||||
groupedByProduct.computeIfAbsent(project.getProductId(), k -> new ArrayList<>()).add(project);
|
||||
}
|
||||
List<ProjectDO> orphanProjects = groupedByProduct.remove(null);
|
||||
if (orphanProjects == null) {
|
||||
orphanProjects = Collections.emptyList();
|
||||
}
|
||||
|
||||
// 3. 可见产品(启用/暂停,产品域数据权限):directionCount 口径 + 零项目占位组候选
|
||||
List<ProductDO> visibleProducts = selectVisibleGroupProducts(productScope);
|
||||
|
||||
// 4. "全部"口径且无其他筛选时,补零项目产品占位组
|
||||
boolean defaultScope = !StringUtils.hasText(reqVO.getStatusCode()) && !StringUtils.hasText(reqVO.getKeyword())
|
||||
&& !StringUtils.hasText(reqVO.getProjectType()) && !Boolean.TRUE.equals(reqVO.getOrphanOnly());
|
||||
if (defaultScope) {
|
||||
visibleProducts.stream()
|
||||
.filter(p -> reqVO.getProductId() == null || p.getId().equals(reqVO.getProductId()))
|
||||
.forEach(p -> groupedByProduct.computeIfAbsent(p.getId(), k -> new ArrayList<>()));
|
||||
}
|
||||
|
||||
// 5. 组排序:方向编码升序(同方向相邻)→ 方向内产品创建时间倒序;产品记录缺失的组殿后;游离组固定最后
|
||||
Map<Long, ProductDO> productMap = loadGroupProducts(groupedByProduct.keySet(), visibleProducts);
|
||||
List<Long> orderedProductIds = sortGroupProductIds(groupedByProduct.keySet(), productMap);
|
||||
|
||||
// 6. 组维度分页(游离组算最后一个组)
|
||||
boolean hasOrphanGroup = !orphanProjects.isEmpty();
|
||||
int totalGroups = orderedProductIds.size() + (hasOrphanGroup ? 1 : 0);
|
||||
int fromIndex = (reqVO.getPageNo() - 1) * reqVO.getPageSize();
|
||||
int toIndex = Math.min(fromIndex + reqVO.getPageSize(), totalGroups);
|
||||
|
||||
ProjectGroupPageRespVO respVO = new ProjectGroupPageRespVO();
|
||||
respVO.setTotal((long) totalGroups);
|
||||
respVO.setProjectTotal((long) matched.size());
|
||||
respVO.setOrphanTotal((long) orphanProjects.size());
|
||||
respVO.setDirectionCount((int) visibleProducts.stream()
|
||||
.map(ProductDO::getDirectionCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.distinct()
|
||||
.count());
|
||||
if (fromIndex >= totalGroups) {
|
||||
respVO.setList(Collections.emptyList());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
// 7. 当页组:产品组在前,游离组若落在当页区间则最后
|
||||
List<Long> pageProductIds = orderedProductIds.subList(fromIndex, Math.min(toIndex, orderedProductIds.size()));
|
||||
boolean orphanOnPage = hasOrphanGroup && toIndex == totalGroups;
|
||||
|
||||
// 8. typeCounts(恒按"全部"口径)与 hasBaseline(非已取消主线):产品级属性,不随筛选与数据权限变化
|
||||
Map<Long, Map<String, Long>> typeCountsMap = new HashMap<>();
|
||||
Map<String, Long> orphanTypeCounts = new HashMap<>();
|
||||
if ((!pageProductIds.isEmpty() || orphanOnPage) && !defaultStatusCodes.isEmpty()) {
|
||||
LambdaQueryWrapperX<ProjectDO> typeWrapper = new LambdaQueryWrapperX<ProjectDO>()
|
||||
.in(ProjectDO::getStatusCode, defaultStatusCodes);
|
||||
boolean withOrphan = orphanOnPage;
|
||||
typeWrapper.and(w -> {
|
||||
if (!pageProductIds.isEmpty()) {
|
||||
w.in(ProjectDO::getProductId, pageProductIds);
|
||||
}
|
||||
if (withOrphan) {
|
||||
if (!pageProductIds.isEmpty()) {
|
||||
w.or();
|
||||
}
|
||||
w.isNull(ProjectDO::getProductId);
|
||||
}
|
||||
});
|
||||
for (ProjectDO project : projectMapper.selectList(typeWrapper)) {
|
||||
if (project.getProjectType() == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Long> counts = project.getProductId() == null
|
||||
? orphanTypeCounts
|
||||
: typeCountsMap.computeIfAbsent(project.getProductId(), k -> new HashMap<>());
|
||||
counts.merge(project.getProjectType(), 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
Set<Long> baselineProductIds = pageProductIds.isEmpty() ? Collections.emptySet()
|
||||
: projectMapper.selectList(new LambdaQueryWrapperX<ProjectDO>()
|
||||
.in(ProjectDO::getProductId, pageProductIds)
|
||||
.in(ProjectDO::getProjectType, ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES)
|
||||
.ne(ProjectDO::getStatusCode, ProjectObjectConstants.STATUS_CANCELLED))
|
||||
.stream().map(ProjectDO::getProductId).collect(Collectors.toSet());
|
||||
|
||||
// 9. 当页组内 topN 项目(更新时间倒序),统一批量转换回填
|
||||
int topN = reqVO.getTopN() == null ? 5 : reqVO.getTopN();
|
||||
Map<Long, List<ProjectDO>> pageGroupProjects = new LinkedHashMap<>();
|
||||
for (Long productId : pageProductIds) {
|
||||
pageGroupProjects.put(productId, topNByUpdateTimeDesc(groupedByProduct.get(productId), topN));
|
||||
}
|
||||
List<ProjectDO> orphanTopN = orphanOnPage ? topNByUpdateTimeDesc(orphanProjects, topN) : Collections.emptyList();
|
||||
List<ProjectDO> allPageProjects = new ArrayList<>();
|
||||
pageGroupProjects.values().forEach(allPageProjects::addAll);
|
||||
allPageProjects.addAll(orphanTopN);
|
||||
Map<Long, ProjectRespVO> convertedById = new HashMap<>();
|
||||
convertProjectListWithNames(allPageProjects).forEach(vo -> convertedById.put(vo.getId(), vo));
|
||||
|
||||
// 10. 组装组 VO 并批量回填产品负责人昵称
|
||||
List<ProjectGroupRespVO> groupList = new ArrayList<>();
|
||||
for (Long productId : pageProductIds) {
|
||||
groupList.add(buildGroupRespVO(productMap.get(productId), productId,
|
||||
groupedByProduct.get(productId), pageGroupProjects.get(productId),
|
||||
typeCountsMap.getOrDefault(productId, Collections.emptyMap()),
|
||||
baselineProductIds.contains(productId), convertedById));
|
||||
}
|
||||
if (orphanOnPage) {
|
||||
groupList.add(buildOrphanGroupRespVO(orphanProjects, orphanTopN, orphanTypeCounts, convertedById));
|
||||
}
|
||||
fillGroupManagerNicknames(groupList);
|
||||
respVO.setList(groupList);
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private List<ProjectDO> selectGroupMatchedProjects(ProjectGroupPageReqVO reqVO, Collection<String> statusCodes,
|
||||
ObjectDataScope projectScope) {
|
||||
// 状态集为空(状态机未配置)时不查库,空 IN 会生成非法 SQL
|
||||
if (projectScope.getState() == ObjectDataScope.State.EMPTY || statusCodes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
||||
wrapper.and(w -> w.like(ProjectDO::getProjectCode, reqVO.getKeyword())
|
||||
.or()
|
||||
.like(ProjectDO::getProjectName, reqVO.getKeyword()));
|
||||
}
|
||||
wrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
|
||||
.eqIfPresent(ProjectDO::getProductId, reqVO.getProductId())
|
||||
.in(ProjectDO::getStatusCode, statusCodes);
|
||||
if (Boolean.TRUE.equals(reqVO.getOrphanOnly())) {
|
||||
wrapper.isNull(ProjectDO::getProductId);
|
||||
}
|
||||
applyProjectScopeCondition(wrapper, projectScope);
|
||||
return projectMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户可见的启用/暂停产品:零项目占位组候选 + directionCount 口径(产品域数据权限)。
|
||||
*/
|
||||
private List<ProductDO> selectVisibleGroupProducts(ObjectDataScope productScope) {
|
||||
if (productScope.getState() == ObjectDataScope.State.EMPTY) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Set<String> liveStatusCodes = resolveLiveProductStatusCodes();
|
||||
if (liveStatusCodes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<ProductDO>()
|
||||
.in(ProductDO::getStatusCode, liveStatusCodes)
|
||||
.orderByDesc(BaseDO::getCreateTime);
|
||||
if (productScope.getState() == ObjectDataScope.State.ID_LIST) {
|
||||
Set<Long> ids = productScope.getIds();
|
||||
Set<String> dcs = productScope.getDirectionCodes();
|
||||
wrapper.and(w -> {
|
||||
boolean addedAny = false;
|
||||
if (!ids.isEmpty()) {
|
||||
w.in(ProductDO::getId, ids);
|
||||
addedAny = true;
|
||||
}
|
||||
if (!dcs.isEmpty()) {
|
||||
if (addedAny) {
|
||||
w.or();
|
||||
}
|
||||
w.in(ProductDO::getDirectionCode, dcs);
|
||||
}
|
||||
});
|
||||
}
|
||||
return productMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组的产品信息:优先用可见产品列表,缺的(如归档产品但仍有命中项目)批量补查。
|
||||
*/
|
||||
private Map<Long, ProductDO> loadGroupProducts(Set<Long> groupProductIds, List<ProductDO> visibleProducts) {
|
||||
Map<Long, ProductDO> productMap = new HashMap<>();
|
||||
visibleProducts.forEach(p -> productMap.put(p.getId(), p));
|
||||
Set<Long> missingIds = groupProductIds.stream()
|
||||
.filter(id -> !productMap.containsKey(id))
|
||||
.collect(Collectors.toSet());
|
||||
if (!missingIds.isEmpty()) {
|
||||
productMapper.selectBatchIds(missingIds).forEach(p -> productMap.put(p.getId(), p));
|
||||
}
|
||||
return productMap;
|
||||
}
|
||||
|
||||
private List<Long> sortGroupProductIds(Set<Long> groupProductIds, Map<Long, ProductDO> productMap) {
|
||||
Comparator<Long> comparator = Comparator
|
||||
// 方向编码升序(同方向的组相邻),产品记录缺失/方向为空的组殿后
|
||||
.comparing((Long id) -> {
|
||||
ProductDO product = productMap.get(id);
|
||||
return product == null || !StringUtils.hasText(product.getDirectionCode())
|
||||
? null : product.getDirectionCode();
|
||||
}, Comparator.nullsLast(Comparator.<String>naturalOrder()))
|
||||
// 方向内按产品创建时间倒序(与产品列表默认排序一致)
|
||||
.thenComparing((Long id) -> {
|
||||
ProductDO product = productMap.get(id);
|
||||
return product == null ? null : product.getCreateTime();
|
||||
}, Comparator.nullsLast(Comparator.<LocalDateTime>reverseOrder()));
|
||||
return groupProductIds.stream().sorted(comparator).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<ProjectDO> topNByUpdateTimeDesc(List<ProjectDO> projects, int topN) {
|
||||
return projects.stream()
|
||||
.sorted(Comparator.comparing(ProjectDO::getUpdateTime, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.limit(topN)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ProjectGroupRespVO buildGroupRespVO(ProductDO product, Long productId, List<ProjectDO> groupProjects,
|
||||
List<ProjectDO> topProjects, Map<String, Long> typeCounts,
|
||||
boolean hasBaseline, Map<Long, ProjectRespVO> convertedById) {
|
||||
ProjectGroupRespVO group = new ProjectGroupRespVO();
|
||||
group.setProductId(productId);
|
||||
if (product != null) {
|
||||
group.setProductName(product.getName());
|
||||
group.setProductCode(product.getCode());
|
||||
group.setDirectionCode(product.getDirectionCode());
|
||||
group.setManagerUserId(product.getManagerUserId());
|
||||
}
|
||||
group.setProjectTotal((long) groupProjects.size());
|
||||
group.setProjects(topProjects.stream().map(p -> convertedById.get(p.getId())).collect(Collectors.toList()));
|
||||
group.setTypeCounts(typeCounts);
|
||||
group.setHasBaseline(hasBaseline);
|
||||
group.setOrphan(false);
|
||||
return group;
|
||||
}
|
||||
|
||||
private ProjectGroupRespVO buildOrphanGroupRespVO(List<ProjectDO> orphanProjects, List<ProjectDO> topProjects,
|
||||
Map<String, Long> typeCounts,
|
||||
Map<Long, ProjectRespVO> convertedById) {
|
||||
ProjectGroupRespVO group = new ProjectGroupRespVO();
|
||||
group.setProductName("游离项目");
|
||||
group.setDirectionCode("");
|
||||
group.setProjectTotal((long) orphanProjects.size());
|
||||
group.setProjects(topProjects.stream().map(p -> convertedById.get(p.getId())).collect(Collectors.toList()));
|
||||
group.setTypeCounts(typeCounts);
|
||||
group.setHasBaseline(false);
|
||||
group.setOrphan(true);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量回填组的产品负责人昵称(一次用户 API 调用)。
|
||||
*/
|
||||
private void fillGroupManagerNicknames(List<ProjectGroupRespVO> groupList) {
|
||||
Set<Long> managerUserIds = groupList.stream()
|
||||
.map(ProjectGroupRespVO::getManagerUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (managerUserIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(managerUserIds);
|
||||
groupList.forEach(group -> {
|
||||
AdminUserRespDTO manager = group.getManagerUserId() == null ? null : userMap.get(group.getManagerUserId());
|
||||
group.setManagerUserNickname(manager == null ? null : manager.getNickname());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProjectStatusCounts}。
|
||||
* EMPTY 直接空集;ALL 走原全表 GROUP BY SQL;ID_LIST 用 wrapper 取 status_code,Java 端 group + count。
|
||||
@@ -527,6 +841,27 @@ class ProjectServiceImpl implements ProjectService {
|
||||
// ID_LIST
|
||||
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||
wrapper.select(ProjectDO::getStatusCode);
|
||||
applyProjectScopeCondition(wrapper, scope);
|
||||
return projectMapper.selectList(wrapper).stream()
|
||||
.filter(p -> p.getStatusCode() != null)
|
||||
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
|
||||
.entrySet().stream()
|
||||
.map(e -> {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("statusCode", e.getKey());
|
||||
row.put("countValue", e.getValue());
|
||||
return row;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* ID_LIST 数据权限注入:项目 id 集合 OR 方向集合(与历史 getProjectPage 行为一致)。
|
||||
*/
|
||||
private void applyProjectScopeCondition(LambdaQueryWrapperX<ProjectDO> wrapper, ObjectDataScope scope) {
|
||||
if (scope.getState() != ObjectDataScope.State.ID_LIST) {
|
||||
return;
|
||||
}
|
||||
Set<Long> ids = scope.getIds();
|
||||
Set<String> dcs = scope.getDirectionCodes();
|
||||
wrapper.and(w -> {
|
||||
@@ -542,17 +877,35 @@ class ProjectServiceImpl implements ProjectService {
|
||||
w.in(ProjectDO::getDirectionCode, dcs);
|
||||
}
|
||||
});
|
||||
return projectMapper.selectList(wrapper).stream()
|
||||
.filter(p -> p.getStatusCode() != null)
|
||||
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
|
||||
.entrySet().stream()
|
||||
.map(e -> {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("statusCode", e.getKey());
|
||||
row.put("countValue", e.getValue());
|
||||
return row;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* DO 批量转 RespVO 并回填产品名 / 负责人昵称(一次批量查产品、一次批量查用户,禁止逐条)。
|
||||
* page 接口与分组接口共用,保证两接口项目行字段一致。
|
||||
*/
|
||||
private List<ProjectRespVO> convertProjectListWithNames(List<ProjectDO> projects) {
|
||||
if (projects == null || projects.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<Long> productIds = projects.stream()
|
||||
.map(ProjectDO::getProductId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, ProductDO> productMap = new HashMap<>();
|
||||
if (!productIds.isEmpty()) {
|
||||
productMapper.selectBatchIds(productIds).forEach(p -> productMap.put(p.getId(), p));
|
||||
}
|
||||
Set<Long> userIds = projects.stream()
|
||||
.map(ProjectDO::getManagerUserId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, AdminUserRespDTO> userMap = userIds.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: adminUserApi.getUserMap(userIds);
|
||||
return projects.stream().map(project -> {
|
||||
ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class);
|
||||
ProductDO product = project.getProductId() == null ? null : productMap.get(project.getProductId());
|
||||
respVO.setProductName(product == null ? null : product.getName());
|
||||
AdminUserRespDTO manager = project.getManagerUserId() == null ? null : userMap.get(project.getManagerUserId());
|
||||
respVO.setManagerUserNickname(manager == null ? null : manager.getNickname());
|
||||
return respVO;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private String getProductName(Long productId) {
|
||||
|
||||
@@ -41,6 +41,8 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
@@ -63,6 +65,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -130,6 +133,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
private DictDataApi dictDataApi;
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
@Resource
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -172,6 +177,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
// 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过)
|
||||
taskAssigneeService.initializeAssignees(task.getId(), ownerId, executionId, reqVO.getAssigneeUserIds());
|
||||
|
||||
// 站内信通知:创建任务后通知负责人 + 活跃协办人(含操作人自己;通知失败不影响任务创建)
|
||||
publishTaskAssignedNotify(projectId, task, ownerId);
|
||||
|
||||
// 父任务(含祖先)进度按子任务平均自动汇总
|
||||
if (task.getParentTaskId() != null) {
|
||||
recalcParentProgressFrom(task.getParentTaskId());
|
||||
@@ -186,6 +194,25 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
return task.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务后发送「任务指派」站内信事件:接收人 = 负责人 + 活跃协办人(含操作人自己)。
|
||||
* 仅 publish 事件,真正发送由 {@link com.njcn.rdms.module.project.framework.notify.NotifySendEventListener}
|
||||
* 在事务提交后处理;通知失败不影响任务创建。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishTaskAssignedNotify(Long projectId, ProjectTaskDO task, Long ownerId) {
|
||||
List<Long> recipients = new ArrayList<>();
|
||||
recipients.add(ownerId);
|
||||
taskAssigneeMapper.selectActiveListByTaskId(task.getId())
|
||||
.forEach(assignee -> recipients.add(assignee.getUserId()));
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("taskName", task.getTaskTitle());
|
||||
applicationEventPublisher.publishEvent(
|
||||
NotifySendEvent.of(recipients, NotifyTemplateCodeConstants.TASK_ASSIGNED, params));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link NotifySendEventListener} 的单元测试:去重 / 空短路 / 单条失败不中断。
|
||||
*/
|
||||
class NotifySendEventListenerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private NotifySendEventListener listener;
|
||||
|
||||
@Mock
|
||||
private NotifyMessageSendApi notifyMessageSendApi;
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_dedupAndSend() {
|
||||
// 1L 重复两次 + 2L:去重后只发 1L、2L 各一次
|
||||
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 1L, 2L), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(1L), eq("task_assigned"), any());
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_emptyRecipients_noSend() {
|
||||
listener.onNotifySend(NotifySendEvent.of(Collections.emptyList(), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(0)).sendSingleNotifyToAdmin(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_singleFailure_doesNotInterrupt() {
|
||||
// 第一个人发送抛异常,第二个人仍应被发送(兜底不中断)
|
||||
doThrow(new RuntimeException("boom")).when(notifyMessageSendApi)
|
||||
.sendSingleNotifyToAdmin(eq(1L), any(), any());
|
||||
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 2L), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
@@ -43,6 +47,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -455,7 +460,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
|
||||
|
||||
PageResult<ProjectDO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||
PageResult<ProjectRespVO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||
|
||||
assertThat(result.getList()).isEmpty();
|
||||
verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any());
|
||||
@@ -489,6 +494,180 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectPage_fillsProductNameAndManagerNicknameInBatch() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
|
||||
ProjectDO p1 = new ProjectDO();
|
||||
p1.setId(101L);
|
||||
p1.setProductId(11L);
|
||||
p1.setManagerUserId(21L);
|
||||
ProjectDO p2 = new ProjectDO();
|
||||
p2.setId(102L); // 游离且无负责人,回填应安全跳过
|
||||
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any()))
|
||||
.thenReturn(new PageResult<>(List.of(p1, p2), 2L));
|
||||
|
||||
ProductDO product = new ProductDO();
|
||||
product.setId(11L);
|
||||
product.setName("智能网关");
|
||||
when(productMapper.selectBatchIds(Set.of(11L))).thenReturn(List.of(product));
|
||||
|
||||
AdminUserRespDTO manager = new AdminUserRespDTO();
|
||||
manager.setId(21L);
|
||||
manager.setNickname("张三");
|
||||
when(adminUserApi.getUserMap(Set.of(21L))).thenReturn(Map.of(21L, manager));
|
||||
|
||||
PageResult<ProjectRespVO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||
|
||||
assertEquals(2, result.getList().size());
|
||||
assertEquals("智能网关", result.getList().get(0).getProductName());
|
||||
assertEquals("张三", result.getList().get(0).getManagerUserNickname());
|
||||
assertEquals(102L, result.getList().get(1).getId());
|
||||
// 批量回填:用户 API 只允许调用一次
|
||||
verify(adminUserApi, times(1)).getUserMap(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_returnsEmpty_whenScopesEmpty() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());
|
||||
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(new ProjectGroupPageReqVO());
|
||||
|
||||
assertEquals(0L, respVO.getTotal());
|
||||
assertEquals(0L, respVO.getProjectTotal());
|
||||
assertEquals(0L, respVO.getOrphanTotal());
|
||||
assertEquals(0, respVO.getDirectionCount());
|
||||
assertThat(respVO.getList()).isEmpty();
|
||||
verify(projectMapper, never()).selectList(any(LambdaQueryWrapperX.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_groupsByProduct_orphanLast_topNTruncated() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());
|
||||
|
||||
// 产品 11(方向 dir_a)3 个项目、产品 12(方向 dir_b)1 个项目、游离 1 个
|
||||
ProjectDO p1 = createGroupProject(101L, 11L, "main", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
ProjectDO p2 = createGroupProject(102L, 11L, "contract", LocalDateTime.of(2026, 6, 3, 10, 0));
|
||||
ProjectDO p3 = createGroupProject(103L, 11L, "contract", LocalDateTime.of(2026, 6, 2, 10, 0));
|
||||
ProjectDO p4 = createGroupProject(104L, 12L, "tech_support", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
ProjectDO p5 = createGroupProject(105L, null, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
// selectList 调用次序与实现一致:① 命中项目 ② typeCounts(四状态) ③ hasBaseline(主线)
|
||||
when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
|
||||
.thenReturn(List.of(p1, p2, p3, p4, p5),
|
||||
List.of(p1, p2, p3, p4, p5),
|
||||
List.of(p1));
|
||||
ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", 21L);
|
||||
ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
|
||||
when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
|
||||
when(adminUserApi.getUserMap(any())).thenReturn(Collections.emptyMap());
|
||||
|
||||
ProjectGroupPageReqVO reqVO = new ProjectGroupPageReqVO();
|
||||
reqVO.setTopN(2);
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(reqVO);
|
||||
|
||||
assertEquals(3L, respVO.getTotal()); // 两个产品组 + 游离组
|
||||
assertEquals(5L, respVO.getProjectTotal());
|
||||
assertEquals(1L, respVO.getOrphanTotal());
|
||||
assertEquals(2, respVO.getDirectionCount());
|
||||
assertEquals(3, respVO.getList().size());
|
||||
|
||||
ProjectGroupRespVO group11 = respVO.getList().get(0); // dir_a 在 dir_b 前
|
||||
assertEquals(11L, group11.getProductId());
|
||||
assertEquals(3L, group11.getProjectTotal());
|
||||
assertEquals(2, group11.getProjects().size()); // topN 截断
|
||||
assertEquals(102L, group11.getProjects().get(0).getId()); // 更新时间倒序
|
||||
assertEquals(103L, group11.getProjects().get(1).getId());
|
||||
assertEquals(Boolean.TRUE, group11.getHasBaseline());
|
||||
assertEquals(Map.of("main", 1L, "contract", 2L), group11.getTypeCounts());
|
||||
|
||||
ProjectGroupRespVO orphanGroup = respVO.getList().get(2); // 游离组殿后
|
||||
assertEquals(Boolean.TRUE, orphanGroup.getOrphan());
|
||||
assertEquals("游离项目", orphanGroup.getProductName());
|
||||
assertEquals(1L, orphanGroup.getProjectTotal());
|
||||
assertEquals(Map.of("contract", 1L), orphanGroup.getTypeCounts());
|
||||
assertEquals(Boolean.FALSE, orphanGroup.getHasBaseline());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_appendsZeroProjectGroups_onDefaultScope() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());
|
||||
|
||||
ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
|
||||
.thenReturn(List.of(p1), List.of(p1), Collections.emptyList());
|
||||
ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", null);
|
||||
ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
|
||||
when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
|
||||
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(new ProjectGroupPageReqVO());
|
||||
|
||||
assertEquals(2L, respVO.getTotal()); // 产品 11 命中组 + 产品 12 零项目占位组,无游离组
|
||||
ProjectGroupRespVO zeroGroup = respVO.getList().get(1);
|
||||
assertEquals(12L, zeroGroup.getProductId());
|
||||
assertEquals(0L, zeroGroup.getProjectTotal());
|
||||
assertThat(zeroGroup.getProjects()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_skipsZeroGroupsAndMisses_whenStatusFiltered() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());
|
||||
|
||||
ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
|
||||
.thenReturn(List.of(p1), List.of(p1), Collections.emptyList());
|
||||
ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", null);
|
||||
ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
|
||||
when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
|
||||
|
||||
ProjectGroupPageReqVO reqVO = new ProjectGroupPageReqVO();
|
||||
reqVO.setStatusCode("active");
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(reqVO);
|
||||
|
||||
assertEquals(1L, respVO.getTotal()); // 仅产品 11;产品 12 不补占位;游离无命中不返回
|
||||
assertEquals(11L, respVO.getList().get(0).getProductId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectOverviewSummary_includesOrphanCount() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(projectMapper.selectStatusCountList()).thenReturn(Collections.emptyList());
|
||||
when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(Collections.emptyList());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(projectMapper.selectCount(any(LambdaQueryWrapperX.class))).thenReturn(3L);
|
||||
|
||||
ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary();
|
||||
|
||||
assertEquals(3L, respVO.getOrphanCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
|
||||
Long projectId = 1003L;
|
||||
@@ -663,4 +842,57 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
return mockedStatic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组接口测试用状态机行:status=0(启用)。"全部"口径与产品存续状态均由状态机推导,测试必须配齐。
|
||||
*/
|
||||
private ObjectStatusModelDO createGroupStatusModel(String objectType, String statusCode, boolean terminalFlag) {
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType(objectType);
|
||||
statusModel.setStatusCode(statusCode);
|
||||
statusModel.setStatus(0);
|
||||
statusModel.setTerminalFlag(terminalFlag);
|
||||
return statusModel;
|
||||
}
|
||||
|
||||
/** 项目域状态机 stub:启用状态全集(含 cancelled/archived,推导逻辑应将其排除)。 */
|
||||
private List<ObjectStatusModelDO> projectStatusModels() {
|
||||
return List.of(
|
||||
createGroupStatusModel("project", "pending", false),
|
||||
createGroupStatusModel("project", "active", false),
|
||||
createGroupStatusModel("project", "paused", false),
|
||||
createGroupStatusModel("project", "completed", true),
|
||||
createGroupStatusModel("project", "cancelled", true),
|
||||
createGroupStatusModel("project", "archived", true));
|
||||
}
|
||||
|
||||
/** 产品域状态机 stub:启用/暂停为非终态,归档/废弃为终态(推导应只取非终态)。 */
|
||||
private List<ObjectStatusModelDO> productStatusModels() {
|
||||
return List.of(
|
||||
createGroupStatusModel("product", "active", false),
|
||||
createGroupStatusModel("product", "paused", false),
|
||||
createGroupStatusModel("product", "archived", true),
|
||||
createGroupStatusModel("product", "abandoned", true));
|
||||
}
|
||||
|
||||
private ProjectDO createGroupProject(Long id, Long productId, String projectType, LocalDateTime updateTime) {
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(id);
|
||||
project.setProductId(productId);
|
||||
project.setProjectType(projectType);
|
||||
project.setStatusCode("active");
|
||||
project.setUpdateTime(updateTime);
|
||||
return project;
|
||||
}
|
||||
|
||||
private ProductDO createGroupProduct(Long id, String name, String directionCode, Long managerUserId) {
|
||||
ProductDO product = new ProductDO();
|
||||
product.setId(id);
|
||||
product.setName(name);
|
||||
product.setDirectionCode(directionCode);
|
||||
product.setStatusCode("active");
|
||||
product.setManagerUserId(managerUserId);
|
||||
product.setCreateTime(LocalDateTime.of(2026, 5, 1, 0, 0));
|
||||
return product;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link ProjectTaskServiceImpl} 的单元测试 —— 任务指派站内信埋点。
|
||||
* 仅验证埋点方法 {@code publishTaskAssignedNotify} 的收件人/参数组织与事件发布;
|
||||
* createTask 端到端正确性由运行态联调验证。
|
||||
*/
|
||||
class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private ProjectTaskServiceImpl projectTaskService;
|
||||
|
||||
@Mock
|
||||
private ProjectMapper projectMapper;
|
||||
@Mock
|
||||
private TaskAssigneeMapper taskAssigneeMapper;
|
||||
@Mock
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Test
|
||||
void testPublishTaskAssignedNotify_recipientsAndParams() {
|
||||
ProjectTaskDO task = new ProjectTaskDO();
|
||||
task.setId(1000L);
|
||||
task.setTaskTitle("联调任务");
|
||||
// 活跃协办人 2L、3L
|
||||
TaskAssigneeDO a2 = new TaskAssigneeDO();
|
||||
a2.setUserId(2L);
|
||||
TaskAssigneeDO a3 = new TaskAssigneeDO();
|
||||
a3.setUserId(3L);
|
||||
when(taskAssigneeMapper.selectActiveListByTaskId(1000L)).thenReturn(Arrays.asList(a2, a3));
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setProjectName("演示项目");
|
||||
when(projectMapper.selectById(50L)).thenReturn(project);
|
||||
|
||||
projectTaskService.publishTaskAssignedNotify(50L, task, 1L);
|
||||
|
||||
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
NotifySendEvent event = captor.getValue();
|
||||
assertEquals(NotifyTemplateCodeConstants.TASK_ASSIGNED, event.getTemplateCode());
|
||||
assertTrue(event.getUserIds().contains(1L)); // 负责人
|
||||
assertTrue(event.getUserIds().contains(2L)); // 协办人
|
||||
assertTrue(event.getUserIds().contains(3L)); // 协办人
|
||||
assertEquals("演示项目", event.getParams().get("projectName"));
|
||||
assertEquals("联调任务", event.getParams().get("taskName"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.njcn.rdms.module.system.api.notify;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO;
|
||||
import com.njcn.rdms.module.system.enums.ApiConstants;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 站内信发送 API(跨模块统一入口契约)。
|
||||
*
|
||||
* <p>业务方(如 project)经此发送站内信,不依赖 system-boot 内部发送实现。
|
||||
* userType 在能力层固定 ADMIN,不暴露给业务方。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@FeignClient(name = ApiConstants.NAME)
|
||||
@Tag(name = "RPC 服务 - 站内信发送") // 对 NotifySendService 的封装,供其它模块统一入口调用
|
||||
public interface NotifyMessageSendApi {
|
||||
|
||||
String PREFIX = ApiConstants.PREFIX + "/notify-message-send";
|
||||
|
||||
@PostMapping(PREFIX + "/send-single")
|
||||
@Operation(summary = "发送单条站内信")
|
||||
CommonResult<Long> sendSingleNotify(@Valid @RequestBody NotifySingleSendReqDTO reqDTO);
|
||||
|
||||
/**
|
||||
* 便捷方法:发送单条站内信给管理后台用户(userType 固定 ADMIN,由实现层处理)。
|
||||
*
|
||||
* @param userId 接收用户编号
|
||||
* @param templateCode 模板编码(场景标识,发送前模板必须已配置)
|
||||
* @param templateParams 模板参数
|
||||
*/
|
||||
default void sendSingleNotifyToAdmin(Long userId, String templateCode, Map<String, Object> templateParams) {
|
||||
sendSingleNotify(new NotifySingleSendReqDTO().setUserId(userId)
|
||||
.setTemplateCode(templateCode).setTemplateParams(templateParams)).checkError();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.njcn.rdms.module.system.api.notify.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 发送单条站内信 Request DTO(跨模块统一入口入参)。
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Schema(description = "RPC 服务 - 发送单条站内信 Request DTO")
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class NotifySingleSendReqDTO {
|
||||
|
||||
@Schema(description = "接收用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
@NotNull(message = "接收用户编号不能为空")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "站内信模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "task_assigned")
|
||||
@NotNull(message = "模板编码不能为空")
|
||||
private String templateCode;
|
||||
|
||||
@Schema(description = "模板参数(占位符 -> 值)", example = "{\"taskName\":\"联调\"}")
|
||||
private Map<String, Object> templateParams;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.njcn.rdms.module.system.api.notify;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO;
|
||||
import com.njcn.rdms.module.system.service.notify.NotifySendService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* {@link NotifyMessageSendApi} 的实现:委托现有 {@link NotifySendService},不重写发送逻辑。
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@RestController // 提供 RESTful API 接口,给 Feign 调用
|
||||
@Validated
|
||||
@Hidden
|
||||
public class NotifyMessageSendApiImpl implements NotifyMessageSendApi {
|
||||
|
||||
@Resource
|
||||
private NotifySendService notifySendService;
|
||||
|
||||
@Override
|
||||
public CommonResult<Long> sendSingleNotify(NotifySingleSendReqDTO reqDTO) {
|
||||
Long logId = notifySendService.sendSingleNotifyToAdmin(
|
||||
reqDTO.getUserId(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
|
||||
return success(logId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -99,4 +99,13 @@ public class NoticeController {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/recent")
|
||||
@Operation(summary = "获取最近公告", description = "工作台首页用,登录即可访问,仅返回正常状态公告")
|
||||
public CommonResult<List<NoticeRespVO>> getRecentNotices(
|
||||
@RequestParam(name = "size", defaultValue = "3") Integer size,
|
||||
@RequestParam(name = "type", required = false) Integer type) {
|
||||
List<NoticeDO> list = noticeService.getRecentNotices(size, type);
|
||||
return success(BeanUtils.toBean(list, NoticeRespVO.class));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public class NotifyMessageMyPageReqVO extends PageParam {
|
||||
@Schema(description = "是否已读", example = "true")
|
||||
private Boolean readStatus;
|
||||
|
||||
@Schema(description = "关键字,对消息正文模糊匹配;不传或空串 = 不过滤", example = "指派")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime[] createTime;
|
||||
|
||||
@@ -17,4 +17,17 @@ public interface NoticeMapper extends BaseMapperX<NoticeDO> {
|
||||
.orderByDesc(NoticeDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近 N 条公告:仅 status 正常(CommonStatusEnum.ENABLE=0),可按 type 过滤,按 id 倒序取 size 条。
|
||||
* type 为 null 时不过滤类型;type 动态透传,不做代码侧白名单校验。
|
||||
*/
|
||||
default java.util.List<NoticeDO> selectRecentList(Integer size, Integer type) {
|
||||
int limit = (size == null || size <= 0) ? 3 : size;
|
||||
return selectList(new LambdaQueryWrapperX<NoticeDO>()
|
||||
.eq(NoticeDO::getStatus, com.njcn.rdms.framework.common.enums.CommonStatusEnum.ENABLE.getStatus())
|
||||
.eqIfPresent(NoticeDO::getType, type)
|
||||
.orderByDesc(NoticeDO::getId)
|
||||
.last("LIMIT " + limit));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -29,9 +29,12 @@ public interface NotifyMessageMapper extends BaseMapperX<NotifyMessageDO> {
|
||||
default PageResult<NotifyMessageDO> selectPage(NotifyMessageMyPageReqVO reqVO, Long userId, Integer userType) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<NotifyMessageDO>()
|
||||
.eqIfPresent(NotifyMessageDO::getReadStatus, reqVO.getReadStatus())
|
||||
// 关键字对最终渲染正文模糊匹配;空串/不传由 likeIfPresent 跳过,不影响存量调用
|
||||
.likeIfPresent(NotifyMessageDO::getTemplateContent, reqVO.getKeyword())
|
||||
.betweenIfPresent(NotifyMessageDO::getCreateTime, reqVO.getCreateTime())
|
||||
.eq(NotifyMessageDO::getUserId, userId)
|
||||
.eq(NotifyMessageDO::getUserType, userType)
|
||||
// 雪花 id 按时间单调递增,id 倒序 = 收到时间倒序且排序唯一稳定(与前端分页口径约定一致,勿改为按 read_status/read_time 排序)
|
||||
.orderByDesc(NotifyMessageDO::getId));
|
||||
}
|
||||
|
||||
|
||||
@@ -57,4 +57,13 @@ public interface NoticeService {
|
||||
*/
|
||||
NoticeDO getNotice(Long id);
|
||||
|
||||
/**
|
||||
* 获取最近 N 条公告(工作台首页用):仅正常状态,可按 type 过滤。
|
||||
*
|
||||
* @param size 条数,默认 3
|
||||
* @param type 公告类型({@link com.njcn.rdms.module.system.enums.notice.NoticeTypeEnum}),null 表示不过滤
|
||||
* @return 公告列表
|
||||
*/
|
||||
List<NoticeDO> getRecentNotices(Integer size, Integer type);
|
||||
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ public class NoticeServiceImpl implements NoticeService {
|
||||
return noticeMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NoticeDO> getRecentNotices(Integer size, Integer type) {
|
||||
int limit = (size == null || size <= 0) ? 3 : size;
|
||||
return noticeMapper.selectRecentList(limit, type);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void validateNoticeExists(Long id) {
|
||||
if (id == null) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.njcn.rdms.module.system.dal.mysql.notify;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.system.controller.admin.notify.vo.message.NotifyMessageMyPageReqVO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link NotifyMessageMapper} 的单元测试 —— 我的站内信分页(keyword 检索 + 排序口径)。
|
||||
*/
|
||||
class NotifyMessageMapperTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initMyBatisPlusTableInfo() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), NotifyMessageDO.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private String captureMyPageSqlSegment(NotifyMessageMyPageReqVO reqVO) {
|
||||
NotifyMessageMapper mapper = mock(NotifyMessageMapper.class, invocation -> invocation.callRealMethod());
|
||||
doReturn(PageResult.empty()).when(mapper).selectPage(eq(reqVO), any(Wrapper.class));
|
||||
|
||||
mapper.selectPage(reqVO, 1L, 2);
|
||||
|
||||
ArgumentCaptor<Wrapper<NotifyMessageDO>> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mapper).selectPage(eq(reqVO), wrapperCaptor.capture());
|
||||
return wrapperCaptor.getValue().getSqlSegment().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void myPage_keywordShouldFilterTemplateContentByLike() {
|
||||
NotifyMessageMyPageReqVO reqVO = new NotifyMessageMyPageReqVO();
|
||||
reqVO.setKeyword("指派");
|
||||
reqVO.setReadStatus(false);
|
||||
|
||||
String sqlSegment = captureMyPageSqlSegment(reqVO);
|
||||
|
||||
assertTrue(sqlSegment.contains("template_content like"));
|
||||
assertTrue(sqlSegment.contains("read_status"));
|
||||
assertTrue(sqlSegment.contains("user_id"));
|
||||
assertTrue(sqlSegment.contains("user_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void myPage_blankKeywordShouldNotAddLikeCondition() {
|
||||
NotifyMessageMyPageReqVO noKeyword = new NotifyMessageMyPageReqVO();
|
||||
assertFalse(captureMyPageSqlSegment(noKeyword).contains("like"));
|
||||
|
||||
NotifyMessageMyPageReqVO blankKeyword = new NotifyMessageMyPageReqVO();
|
||||
blankKeyword.setKeyword("");
|
||||
assertFalse(captureMyPageSqlSegment(blankKeyword).contains("like"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void myPage_shouldOrderByIdDescOnly() {
|
||||
String sqlSegment = captureMyPageSqlSegment(new NotifyMessageMyPageReqVO());
|
||||
|
||||
// 与前端约定的分页口径:id 倒序(= 收到时间倒序、唯一稳定),不随读状态重排
|
||||
assertTrue(sqlSegment.contains("order by id desc"));
|
||||
assertFalse(sqlSegment.contains("read_status desc"));
|
||||
assertFalse(sqlSegment.contains("read_time"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.njcn.rdms.module.system.service.notice;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.notice.NoticeDO;
|
||||
import com.njcn.rdms.module.system.dal.mysql.notice.NoticeMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link NoticeServiceImpl} 的单元测试 —— 工作台最近公告查询。
|
||||
*/
|
||||
class NoticeServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private NoticeServiceImpl noticeService;
|
||||
|
||||
@Mock
|
||||
private NoticeMapper noticeMapper;
|
||||
|
||||
@Test
|
||||
void testGetRecentNotices_delegatesToMapper() {
|
||||
NoticeDO notice = new NoticeDO();
|
||||
notice.setId(100L);
|
||||
when(noticeMapper.selectRecentList(3, 2)).thenReturn(singletonList(notice));
|
||||
|
||||
List<NoticeDO> result = noticeService.getRecentNotices(3, 2);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(100L, result.get(0).getId());
|
||||
}
|
||||
}
|
||||
266
项目列表产品分组-后端接口诉求-2026-06-10.html
Normal file
266
项目列表产品分组-后端接口诉求-2026-06-10.html
Normal file
@@ -0,0 +1,266 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<title>项目列表按产品分组:后端待确认事项与接口诉求</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--border-strong: #d1d5db;
|
||||
--text: #1f2937;
|
||||
--text-soft: #6b7280;
|
||||
--text-muted: #9ca3af;
|
||||
--primary: #2563eb;
|
||||
--primary-soft: #dbeafe;
|
||||
|
||||
--ok: #047857;
|
||||
--ok-bg: #d1fae5;
|
||||
--warn: #b45309;
|
||||
--warn-bg: #fef3c7;
|
||||
--bad: #b91c1c;
|
||||
--bad-bg: #fee2e2;
|
||||
|
||||
--code: #b45309;
|
||||
--code-bg: #f3f4f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px 80px;
|
||||
}
|
||||
|
||||
.head { margin-bottom: 22px; }
|
||||
.head h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; line-height: 1.4; }
|
||||
.head .sub { color: var(--text-soft); font-size: 13px; }
|
||||
|
||||
section { margin-top: 28px; }
|
||||
section > h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
section h3 { font-size: 15px; font-weight: 700; margin: 18px 0 8px; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 18px 22px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
table.cmp {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
table.cmp th, table.cmp td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.cmp th { background: #f9fafb; font-weight: 700; white-space: nowrap; }
|
||||
table.cmp td code, p code, li code {
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--code);
|
||||
background: var(--code-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
pre .c { color: #9ca3af; }
|
||||
|
||||
.tag-ok, .tag-warn, .tag-bad {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 1px 10px;
|
||||
vertical-align: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-ok { color: var(--ok); background: var(--ok-bg); }
|
||||
.tag-warn { color: var(--warn); background: var(--warn-bg); }
|
||||
.tag-bad { color: var(--bad); background: var(--bad-bg); }
|
||||
|
||||
ul, ol { margin: 8px 0; padding-left: 22px; }
|
||||
li { margin: 4px 0; }
|
||||
.note { color: var(--text-soft); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<div class="head">
|
||||
<h1>项目列表按产品分组:后端待确认事项与接口诉求</h1>
|
||||
<div class="sub">2026-06-10 · 前端发起 · 涉及模块:project/project(项目)、system/dict(字典)</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>0. 背景</h2>
|
||||
<div class="card">
|
||||
<p>项目列表页将从"平铺分页"改为"按所属产品聚合展示":同一产品下的项目(基线/合同/技术支持等类型)需要聚拢在一起查看,不能被分页切散。现有 <code>GET /project/project/page</code> 平铺分页无法支撑该口径,需要后端补充以下能力。</p>
|
||||
<p>前端已按本文档第 2~4 节的契约<b>先行开发</b>(过渡期用现有平铺接口在前端聚合模拟),后端接口就绪后联调切换,<b>不阻塞后端排期</b>,但请反馈预计时间点。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>1. 待查询确认:rdms_project_type 字典数据 <span class="tag-warn">请尽快回复,阻塞前端一处常量</span></h2>
|
||||
<div class="card">
|
||||
<p>请提供字典 <code>rdms_project_type</code>(项目类型)的<b>完整字典数据清单</b>(每项的 value / label / 启用状态),特别是:</p>
|
||||
<ul>
|
||||
<li><b>"基线"类型对应的字典 value 是什么</b>(前端需写入常量,用于"建立基线"入口预填与缺基线判定);</li>
|
||||
<li>除 基线 / 合同 / 技术支持 外是否还有其他在用类型值。</li>
|
||||
</ul>
|
||||
<p class="note">补充确认(口头已确认,烦请最终对齐):"基线项目每产品唯一"由后端在创建/更新项目时做唯一性校验兜底,前端仅做体验层提示。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. 接口诉求一:项目按产品分组分页查询(新增) <span class="tag-bad">核心依赖</span></h2>
|
||||
|
||||
<h3>2.1 建议路径</h3>
|
||||
<div class="card">
|
||||
<p><code>GET /project/project/group-page</code>(路径可由后端按规范调整,前端只在 service 层适配一处)</p>
|
||||
<p>数据权限口径与现有 <code>/project/project/page</code> 一致:只返回当前用户可见的项目,按其归属产品聚合。</p>
|
||||
</div>
|
||||
|
||||
<h3>2.2 入参</h3>
|
||||
<table class="cmp">
|
||||
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
||||
<tr><td><code>pageNo</code> / <code>pageSize</code></td><td>int</td><td>是</td><td><b>产品组维度</b>分页:一页返回 pageSize 个产品组(含组内项目),不是项目行分页。前端当前 pageSize=10。</td></tr>
|
||||
<tr><td><code>keyword</code></td><td>string</td><td>否</td><td>项目编码 / 名称模糊匹配(口径同现有 page 接口)。</td></tr>
|
||||
<tr><td><code>productId</code></td><td>string</td><td>否</td><td>限定单个产品。</td></tr>
|
||||
<tr><td><code>projectType</code></td><td>string</td><td>否</td><td>项目类型字典 value。</td></tr>
|
||||
<tr><td><code>statusCode</code></td><td>string</td><td>否</td><td>单状态过滤。<b>不传 = "全部"口径:pending / active / paused / completed 四种状态,不含 cancelled / archived。</b></td></tr>
|
||||
<tr><td><code>orphanOnly</code></td><td>boolean</td><td>否</td><td>true = 仅返回"游离组"(productId 为空的项目聚合为一个特殊组)。</td></tr>
|
||||
<tr><td><code>topN</code></td><td>int</td><td>否</td><td>每组返回的项目条数上限,建议默认 5(前端当前固定取 5)。</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>2.3 返回结构(示例)</h3>
|
||||
<pre>{
|
||||
"total": 12, <span class="c">// 当前筛选口径下产品组总数(分页 total,含游离组)</span>
|
||||
"projectTotal": 47, <span class="c">// 当前筛选口径下项目总数</span>
|
||||
"directionCount": 3, <span class="c">// 可见产品去重后的方向(directionCode)数</span>
|
||||
"orphanTotal": 3, <span class="c">// 游离项目数(productId 为空)</span>
|
||||
"list": [
|
||||
{
|
||||
"productId": "1923456789012345678", <span class="c">// 字符串!见 4.1</span>
|
||||
"productName": "智能网关",
|
||||
"productCode": "RDMS-P-001",
|
||||
"directionCode": "platform",
|
||||
"managerUserId": "1001",
|
||||
"managerUserNickname": "张三",
|
||||
"projectTotal": 6, <span class="c">// 当前筛选口径下该组项目总数</span>
|
||||
"projects": [ /* 前 topN 条,字段同现有 page 接口的项目行 */ ],
|
||||
"typeCounts": { "baseline值": 1, "合同值": 3, "技术支持值": 2 },
|
||||
"hasBaseline": true,
|
||||
"orphan": false
|
||||
},
|
||||
{
|
||||
"productId": null, <span class="c">// 游离组:未挂产品的项目聚合</span>
|
||||
"productName": "游离项目",
|
||||
"productCode": null,
|
||||
"directionCode": "",
|
||||
"managerUserId": null,
|
||||
"managerUserNickname": null,
|
||||
"projectTotal": 3,
|
||||
"projects": [ /* ... */ ],
|
||||
"typeCounts": { "合同值": 2, "技术支持值": 1 },
|
||||
"hasBaseline": false,
|
||||
"orphan": true
|
||||
}
|
||||
]
|
||||
}</pre>
|
||||
|
||||
<h3>2.4 口径约定(关键,请逐条确认)</h3>
|
||||
<table class="cmp">
|
||||
<tr><th>#</th><th>约定</th><th>说明</th></tr>
|
||||
<tr><td>1</td><td>组内项目排序</td><td>按 <code>updateTime</code> 倒序,返回前 topN 条;<code>projectTotal</code> 为该口径下组内全量计数。</td></tr>
|
||||
<tr><td>2</td><td>无命中组不返回</td><td>传了 <code>statusCode</code> / <code>keyword</code> / <code>projectType</code> 任一筛选时,组内无命中项目的产品组不返回。</td></tr>
|
||||
<tr><td>3</td><td>零项目产品组</td><td>"全部"口径(statusCode 缺省)且无 keyword / projectType 筛选时,<b>返回当前用户可见、状态为 active / paused 的零项目产品组</b>(projectTotal=0、projects=[]),用于"该产品暂无项目"占位与新增引导。</td></tr>
|
||||
<tr><td>4</td><td>typeCounts / hasBaseline 统计口径</td><td>这两个字段是"产品属性",<b>恒按"全部"口径(四种状态)统计</b>,不随 statusCode 入参变化——避免"基线项目已完成时被误判为缺基线"。</td></tr>
|
||||
<tr><td>5</td><td>游离组位置</td><td>游离组(productId 为空)作为列表中<b>最后一个组</b>返回;状态筛选下同样适用约定 2(无命中则不返回该组)。</td></tr>
|
||||
<tr><td>6</td><td>组的排序</td><td>同方向(directionCode)的产品组相邻返回(方向间顺序、方向内产品顺序由后端按现有产品列表默认排序即可)。</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>2.5 组内"展开剩余"的数据来源</h3>
|
||||
<div class="card">
|
||||
<p>不需要新接口:前端用现有 <code>GET /project/project/page</code> 按 <code>productId + statusCode</code> 拉取该组剩余项目。前提是该接口的排序与本接口组内排序一致(updateTime 倒序)——若现有 page 接口默认排序不是 updateTime 倒序,请告知或支持排序参数。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. 接口诉求二、三:存量接口小改 <span class="tag-warn">两处补充</span></h2>
|
||||
|
||||
<h3>3.1 平铺分页支持"未挂产品"筛选</h3>
|
||||
<div class="card">
|
||||
<p><code>GET /project/project/page</code> 需要能表达 <b>productId 为空</b>的筛选语义(如新增 <code>orphanOnly=true</code> 参数,或约定 productId 传特殊值),用于查询"游离项目"(未关联任何产品的项目)。具体参数形式由后端定,前端适配。</p>
|
||||
</div>
|
||||
|
||||
<h3>3.2 概览统计补游离计数</h3>
|
||||
<div class="card">
|
||||
<p><code>GET /project/project/overview-summary</code> 返回体增加:</p>
|
||||
<pre>{
|
||||
"statusCounts": { "active": 10, "pending": 2, ... }, <span class="c">// 现有</span>
|
||||
"orphanCount": 3 <span class="c">// 新增:未挂产品的项目数(按"全部"口径:四种状态)</span>
|
||||
}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. 公共约定</h2>
|
||||
<table class="cmp">
|
||||
<tr><th>#</th><th>约定</th><th>说明</th></tr>
|
||||
<tr><td>4.1</td><td>ID 一律字符串 <span class="tag-bad">必须</span></td><td>所有 Long / 雪花 ID(productId、managerUserId、项目 id 等)在 JSON 中<b>按字符串返回</b>。Long 直接作为 JSON 数字返回会在前端丢精度。</td></tr>
|
||||
<tr><td>4.2</td><td>"全部"状态集合</td><td>pending / active / paused / completed;cancelled / archived 仅在显式传对应 statusCode 时返回。</td></tr>
|
||||
<tr><td>4.3</td><td>项目行字段</td><td>分组接口 <code>projects[]</code> 内的项目对象字段与现有 <code>/project/project/page</code> 返回行保持一致(含 productId / productName / managerUserNickname / progressRate / statusCode / updateTime 等),避免前端双口径。</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. 需要后端反馈的清单</h2>
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li><b>第 1 节</b>:<code>rdms_project_type</code> 字典数据清单 + "基线"的 value(<span class="tag-warn">最优先</span>,一条查询即可,阻塞前端一处常量)。</li>
|
||||
<li><b>第 2 节</b>:分组分页接口的可行性确认 + 2.4 六条口径逐条确认(有异议请直接批注替代方案)+ 排期。</li>
|
||||
<li><b>第 3 节</b>:page 接口游离筛选的参数形式 + overview-summary 补 orphanCount 的排期。</li>
|
||||
<li><b>第 2.5 节</b>:现有 page 接口的默认排序是什么;是否支持/计划支持按 updateTime 倒序。</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user