fix(project): 实现项目和任务实际开始结束日期的自动同步功能。

fix(file): 优化 S3 文件客户端的 URL 处理逻辑。
This commit is contained in:
dk
2026-07-03 17:33:27 +08:00
parent 7f832d819e
commit 9e502e6a80
10 changed files with 190 additions and 71 deletions

View File

@@ -114,6 +114,20 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.eq(ProjectDO::getId, id));
}
/**
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。
*/
default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) {
if (actualStartDate == null && actualEndDate == null) {
return 0;
}
ProjectDO update = new ProjectDO();
update.setActualStartDate(actualStartDate);
update.setActualEndDate(actualEndDate);
return update(update, new LambdaQueryWrapperX<ProjectDO>()
.eq(ProjectDO::getId, id));
}
/**
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate状态不在排除集终态+paused
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。

View File

@@ -12,6 +12,19 @@ import java.util.List;
@Mapper
public interface ProjectStatusLogMapper extends BaseMapperX<ProjectStatusLogDO> {
default ProjectStatusLogDO selectEarliestByProjectIdAndActionType(Long projectId, String actionType) {
if (projectId == null || actionType == null || actionType.isBlank()) {
return null;
}
List<ProjectStatusLogDO> list = selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
.eq(ProjectStatusLogDO::getProjectId, projectId)
.eq(ProjectStatusLogDO::getActionType, actionType.trim())
.orderByAsc(BaseDO::getCreateTime)
.orderByAsc(ProjectStatusLogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
default List<ProjectStatusLogDO> selectListByProjectId(Long projectId, String actionType, LocalDateTime[] operateTime) {
return selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
.eq(ProjectStatusLogDO::getProjectId, projectId)

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.dal.mysql.project.task;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.rdms.framework.common.pojo.PageResult;
@@ -115,16 +116,15 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
}
/**
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖
* 仅更新实际开始/结束日期。显式 set允许把字段清空为 null
*/
default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) {
if (actualStartDate == null && actualEndDate == null) {
if (id == null) {
return 0;
}
ProjectTaskDO update = new ProjectTaskDO();
update.setActualStartDate(actualStartDate);
update.setActualEndDate(actualEndDate);
return update(update, new LambdaQueryWrapperX<ProjectTaskDO>()
return update(null, new LambdaUpdateWrapper<ProjectTaskDO>()
.set(ProjectTaskDO::getActualStartDate, actualStartDate)
.set(ProjectTaskDO::getActualEndDate, actualEndDate)
.eq(ProjectTaskDO::getId, id));
}

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.dal.mysql.project.task;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO;
@@ -58,6 +59,19 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
""")
BigDecimal sumDurationByTaskId(@Param("taskId") Long taskId);
/**
* 项目下所有任务工作日志的最早开始日期。逻辑删除的任务 / 工作日志不参与统计。
*/
@Select("""
SELECT MIN(w.start_date)
FROM rdms_task_worklog w
INNER JOIN rdms_task t ON t.id = w.task_id
WHERE w.deleted = b'0'
AND t.deleted = b'0'
AND t.project_id = #{projectId}
""")
LocalDate selectEarliestStartDateByProjectId(@Param("projectId") Long projectId);
/**
* 批量任务工时小时数汇总,返回 [{taskId, total}]。用于详情/分页装配避免 N+1。
*/
@@ -83,6 +97,22 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
.eq(TaskWorklogDO::getTaskId, taskId)) > 0;
}
/**
* 任务下最早一条工作日志(按 start_date asc, create_time asc, id asc
*/
default TaskWorklogDO selectEarliestByTaskId(Long taskId) {
if (taskId == null) {
return null;
}
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.orderByAsc(TaskWorklogDO::getStartDate)
.orderByAsc(BaseDO::getCreateTime)
.orderByAsc(TaskWorklogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
/**
* 取指定用户在该任务下最新的一条工时(按 end_date desc, create_time desc, id desc
* 用于 owner 填报后回查"本人最新一条"以同步任务进度。
@@ -102,6 +132,24 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
return list.isEmpty() ? null : list.get(0);
}
/**
* 取该任务下指定用户 progressRate=100 的最晚一条工作日志(按 end_date desc, create_time desc, id desc
*/
default TaskWorklogDO selectLatestCompletedByTaskIdAndUserId(Long taskId, Long userId) {
if (taskId == null || userId == null) {
return null;
}
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.eq(TaskWorklogDO::getUserId, userId)
.eq(TaskWorklogDO::getProgressRate, new BigDecimal("100"))
.orderByDesc(TaskWorklogDO::getEndDate)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(TaskWorklogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
/**
* 取该 (taskId, userId) 下 endDate 严格早于给定值的"最近一条",按 end_date desc, id desc 取首行。
* 用于进度单调性校验(与"前一段"比较。excludeId 不为 null 时排除自身(更新场景)。

View File

@@ -68,6 +68,12 @@ public interface ProjectService {
void autoStartProjectIfPending(Long projectId, String triggerAction);
/**
* 以项目下最早一条任务工作日志的 startDate 为优先口径,同步项目实际开始日期。
* 若项目下尚无任务工作日志,则回退到首次 auto_start 状态日志日期;两者都没有时不更新。
*/
void syncActualStartDateFromWorklogs(Long projectId);
/**
* 重算项目进度并落库AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。
* 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。

View File

@@ -44,6 +44,7 @@ import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
@@ -122,6 +123,8 @@ class ProjectServiceImpl implements ProjectService {
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
@Resource
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
@@ -1065,6 +1068,19 @@ class ProjectServiceImpl implements ProjectService {
changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncActualStartDateFromWorklogs(Long projectId) {
if (projectId == null) {
return;
}
ProjectDO project = projectMapper.selectById(projectId);
if (project == null) {
return;
}
syncActualStartDate(project);
}
@Override
public void recalcProgress(Long projectId) {
if (projectId == null) {
@@ -1494,6 +1510,40 @@ class ProjectServiceImpl implements ProjectService {
writeProjectStatusLog(project, actionCode, fromStatus, toStatus, reason);
writeBizAuditLog(project, actionCode, fromStatus, toStatus, null, reason);
if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) {
syncActualStartDate(project);
}
}
/**
* 项目实际开始日期口径:
* 1) 优先取项目下最早一条任务工作日志的 startDate
* 2) 若无工作日志,则回退到首次 auto_start 状态日志的 createTime 所在日期;
* 3) 两者都没有时不更新。
*/
private void syncActualStartDate(ProjectDO project) {
if (project == null || project.getId() == null) {
return;
}
LocalDate targetDate = resolveActualStartDate(project.getId());
if (targetDate == null || Objects.equals(targetDate, project.getActualStartDate())) {
return;
}
projectMapper.updateActualDatesById(project.getId(), targetDate, null);
project.setActualStartDate(targetDate);
}
private LocalDate resolveActualStartDate(Long projectId) {
LocalDate earliestWorklogDate = taskWorklogMapper.selectEarliestStartDateByProjectId(projectId);
if (earliestWorklogDate != null) {
return earliestWorklogDate;
}
ProjectStatusLogDO autoStartLog = projectStatusLogMapper
.selectEarliestByProjectIdAndActionType(projectId, ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
if (autoStartLog == null || autoStartLog.getCreateTime() == null) {
return null;
}
return autoStartLog.getCreateTime().toLocalDate();
}
private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {

View File

@@ -9,8 +9,6 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import java.time.LocalDate;
/**
* 项目任务 Service 接口。
*/
@@ -89,7 +87,7 @@ public interface ProjectTaskService {
/**
* 由工时填报5.11触发的任务自动开始当任务当前处于初始态initialFlag=true
* 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START}
* 动作把任务推进到下一状态,并复用 5.5 的钩子写入 actualStartDate / 状态日志 / 审计日志。
* 动作把任务推进到下一状态,并状态日志 / 审计日志。
* <p>
* 调用方必须已自行确认登录人是 task.ownerId仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。
* 本方法**不走** @CheckObjectPermission因为 5.11 持有的是 PERMISSION_WORKLOG
@@ -101,16 +99,15 @@ public interface ProjectTaskService {
void internalAutoStartByWorklog(ProjectTaskDO task);
/**
* 工作日志填报触发:当任务"实际开始时间"为空时,用给定的开始日期回填,使"任务何时开始"
* 以最早一条工作日志的开始日期为准。owner / 协办人共用,不限任务当前状态。
* 工作日志事实回算任务实际开始/结束日期:
* <ul>
* <li>actualStartDate = 该任务最早一条工作日志的 startDate不区分填报人</li>
* <li>actualEndDate = 该任务负责人本人所填、progressRate=100 的最晚一条工作日志 endDate</li>
* </ul>
* <p>
* 与 {@link #internalAutoStartByWorklog} 解耦:本方法只回填 actualStartDate不动实际结束日期、
* 不推进任务状态)。调用顺序应在 internalAutoStartByWorklog 之前,确保后者的回填钩子见到非空值即跳过,
* 不会再用"提交当天"覆盖工作日志的开始日期。
* <p>
* 任务实际开始时间已有值、或入参为空时静默 return。
* 当日志被删除或改动后,本方法也负责把对应字段清空或改写为新的回算结果。
*/
void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate);
void syncActualDatesFromWorklogs(Long taskId);
/**
* 由执行 cancel 触发的级联取消指定执行下所有未终态的顶层任务parentTaskId IS NULL

View File

@@ -65,7 +65,6 @@ import org.springframework.util.StringUtils;
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;
@@ -90,8 +89,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
@Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
@Resource
private ProjectMapper projectMapper;
@Resource
@@ -447,7 +444,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(current, fromStatus, toStatus);
// 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报
projectExecutionService.onTaskStatusChanged(
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
@@ -455,17 +451,25 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
public void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate) {
if (taskId == null || startDate == null) {
public void syncActualDatesFromWorklogs(Long taskId) {
if (taskId == null) {
return;
}
ProjectTaskDO current = projectTaskMapper.selectById(taskId);
// 仅在实际开始时间为空时回填;已有值(含 internalAutoStartByWorklog 已写过)则保持不变
if (current == null || current.getActualStartDate() != null) {
ProjectTaskDO task = projectTaskMapper.selectById(taskId);
if (task == null) {
return;
}
// 只动开始日期:实际结束日期传 null依据全局 FieldStrategy 不会被覆盖
projectTaskMapper.updateActualDatesById(taskId, startDate, null);
TaskWorklogDO earliestWorklog = taskWorklogMapper.selectEarliestByTaskId(taskId);
TaskWorklogDO latestOwnerCompletedWorklog = task.getOwnerId() == null
? null
: taskWorklogMapper.selectLatestCompletedByTaskIdAndUserId(taskId, task.getOwnerId());
LocalDate actualStartDate = earliestWorklog == null ? null : earliestWorklog.getStartDate();
LocalDate actualEndDate = latestOwnerCompletedWorklog == null ? null : latestOwnerCompletedWorklog.getEndDate();
if (Objects.equals(task.getActualStartDate(), actualStartDate)
&& Objects.equals(task.getActualEndDate(), actualEndDate)) {
return;
}
projectTaskMapper.updateActualDatesById(taskId, actualStartDate, actualEndDate);
}
@Override
@@ -728,41 +732,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|| "resume".equals(actionCode);
}
/**
* 根据通用状态语义位推导实际开始/结束日期:
* - 首次离开初始态fromStatus.initialFlag=true且未填写时写入 actualStartDate
* - 进入终态toStatus.terminalFlag=true且未填写时写入 actualEndDate
*/
private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) {
LocalDate today = LocalDate.now(SERVER_ZONE);
LocalDate newActualStart = null;
LocalDate newActualEnd = null;
if (task.getActualStartDate() == null) {
ObjectStatusModelDO fromModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
if (fromModel != null && Boolean.TRUE.equals(fromModel.getInitialFlag())) {
newActualStart = today;
}
}
if (task.getActualEndDate() == null) {
ObjectStatusModelDO toModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus);
if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) {
newActualEnd = today;
}
}
if (newActualStart == null && newActualEnd == null) {
return;
}
projectTaskMapper.updateActualDatesById(task.getId(), newActualStart, newActualEnd);
if (newActualStart != null) {
task.setActualStartDate(newActualStart);
}
if (newActualEnd != null) {
task.setActualEndDate(newActualEnd);
}
}
private ProjectDO validateEditableProject(Long projectId) {
ProjectDO project = validateProjectExists(projectId);
ObjectStatusModelDO statusModel = objectStatusModelMapper
@@ -916,7 +885,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(task, fromStatus, toStatus);
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算forceCompleteProgress 内部触发项目刷新)
if ("complete".equals(actionCode)) {

View File

@@ -27,6 +27,7 @@ import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
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.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
@@ -70,6 +71,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
private DictDataApi dictDataApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectService projectService;
/**
* 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。
* 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。
@@ -118,10 +121,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
taskWorklogMapper.insert(worklog);
// 实际开始时间为空时用本条工作日志的开始日期回填owner / 协办人一致,不限任务当前状态)。
// 必须在 internalAutoStartByWorklog 之前:先把开始日期落库,自动开始里的回填钩子见到非空值即跳过,
// 不会再用"提交当天"覆盖工作日志填写的开始日期。
projectTaskService.fillActualStartDateIfAbsent(taskId, reqVO.getStartDate());
projectTaskService.syncActualDatesFromWorklogs(taskId);
projectService.syncActualStartDateFromWorklogs(projectId);
// 任意填报人owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),推进任务状态。
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
projectTaskService.internalAutoStartByWorklog(task);
@@ -165,6 +166,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
update.setAttachments(reqVO.getAttachments());
taskWorklogMapper.updateById(update);
projectTaskService.syncActualDatesFromWorklogs(taskId);
projectService.syncActualStartDateFromWorklogs(projectId);
// owner 改自己工时:按"owner 最新一条"重算任务进度
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
@@ -186,6 +189,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN);
}
taskWorklogMapper.deleteById(worklog.getId());
projectTaskService.syncActualDatesFromWorklogs(taskId);
projectService.syncActualStartDateFromWorklogs(projectId);
// 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度;
// 若 owner 已无任何工时syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零)
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {

View File

@@ -114,9 +114,10 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
// 1. 将 url 转换为 path
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path));
// 1. 将入参转换为对象 key
// - upload(...) 这里传进来的是原始 path未编码绝不能先 decode
// - 历史记录 / 对外接口传进来的可能是完整 URL可能已编码这时才需要 decode
String path = resolveObjectKey(url);
// 2.1 情况一:公开访问:无需签名
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
@@ -134,6 +135,23 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
return signedUrl.toString();
}
private String resolveObjectKey(String urlOrPath) {
String cleaned = HttpUtils.removeUrlQuery(urlOrPath);
if (!HttpUtil.isHttp(cleaned) && !HttpUtil.isHttps(cleaned)) {
return cleaned;
}
String path = StrUtil.removePrefix(cleaned, config.getDomain() + "/");
if (StrUtil.equals(path, cleaned)) {
URI uri = URI.create(cleaned);
path = StrUtil.removePrefix(StrUtil.nullToEmpty(uri.getPath()), "/");
if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) {
path = StrUtil.removePrefix(path, config.getBucket() + "/");
}
}
return HttpUtils.decodeUtf8(path);
}
/**
* 基于 bucket + endpoint 构建访问的 Domain 地址
*