fix(task): 修复周期任务到点后无限复制重复任务

Carbon 3 的 addDays() 不接受字符串,自定义天数周期(loop="90")在
refreshLoop() 中抛 TypeError。LoopTask 先提交副本再刷新周期,异常后
副本与原任务的 loop_at 都留在 10 分钟窗口内,每分钟翻倍并在同一轮内
被 chunkById 继续扫到。

- refreshLoop:自定义天数转 int 且必须大于 0,拒绝 0 天周期
- LoopTask:复制、刷新周期、关闭旧周期放入同一事务,失败整体回滚;
  锁定并复核原任务防并发重入;refreshLoop 失败即抛出;只处理本轮
  开始前已存在的任务
- ai-kb:更正周期任务按 loop_at 到点生成(而非完成时生成)、不依赖
  end_at、会复制附件,并补充自定义天数须为正整数
This commit is contained in:
kuaifan 2026-09-30 14:48:43 +00:00
parent 1c90a11211
commit 5a10f59df8
6 changed files with 103 additions and 72 deletions

View File

@ -1177,8 +1177,10 @@ class ProjectTask extends AbstractModel
$this->loop_at = null;
break;
default:
if (Base::isNumber($this->loop)) {
$this->loop_at = $base->addDays($this->loop);
// 自定义天数须为正整数:Carbon 3 的 addDays 不接受字符串,0 天会让新任务立刻再次命中周期
$days = Base::isNumber($this->loop) ? (int)$this->loop : 0;
if ($days > 0) {
$this->loop_at = $base->addDays($days);
} else {
$this->loop_at = null;
$success = false;

View File

@ -2,6 +2,8 @@
namespace App\Tasks;
use App\Exceptions\ApiException;
use App\Models\AbstractModel;
use App\Module\ProjectTaskHandoffRecord;
use App\Models\ProjectFlow;
use App\Models\ProjectFlowItem;
@ -21,7 +23,9 @@ class LoopTask extends AbstractTask
public function start()
{
ProjectTask::whereBetween('loop_at', [
// 只处理本轮开始前已存在的任务,本轮新生成的任务不会在同一轮里被再次扫到
$maxId = (int)ProjectTask::withTrashed()->max('id');
ProjectTask::where('id', '<=', $maxId)->whereBetween('loop_at', [
Carbon::now()->subMinutes(10),
Carbon::now()
])->whereHas('project', function ($query) {
@ -35,54 +39,7 @@ class LoopTask extends AbstractTask
continue;
}
try {
$task = $item->copyTask();
ProjectTaskHandoffRecord::track($task, 'recurring', function () use ($task, $item) {
// 工作流
$projectFlow = ProjectFlow::whereProjectId($task->project_id)->orderByDesc('id')->first();
if ($projectFlow) {
$projectFlowItem = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
// 赋一个开始状态
foreach ($projectFlowItem as $flowItem) {
if ($flowItem->status == 'start') {
$task->flow_item_id = $flowItem->id;
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
if ($flowItem->userids) {
$userids = array_values(array_unique($flowItem->userids));
foreach ($userids as $uid) {
ProjectTaskUser::updateInsert([
'task_id' => $task->id,
'userid' => $uid,
], [
'project_id' => $task->project_id,
'task_pid' => $task->id,
'owner' => 1,
]);
}
}
break;
}
}
}
// 新任务时间、周期
if ($task->start_at) {
$diffSecond = (int)Carbon::parse($task->start_at)->diffInSeconds(Carbon::parse($task->end_at), true);
$task->start_at = Carbon::parse($task->loop_at);
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
}
// 处理子任务
$item->copySubTasks($task, [
'reset_complete' => true,
'sync_time' => true,
]);
//
$task->refreshLoop(true);
$task->addLog("创建任务来自周期任务ID:{$item->id}", [], $task->userid);
});
// 清空旧周期
$item->loop = '';
$item->loop_at = null;
$item->save();
$item->addLog("已创建新的周期任务ID:{$task->id},此任务关闭周期", [], $task->userid);
$this->createLoopTask($item);
} catch (\Throwable $e) {
$item->addLog("生成重复任务失败:" . $e->getMessage(), [], $item->userid);
}
@ -90,6 +47,75 @@ class LoopTask extends AbstractTask
});
}
/**
* 按周期生成新任务并关闭原任务周期
* 复制、刷新周期、关闭旧周期在同一事务内,任一步失败整体回滚,不会留下 loop_at 仍在窗口内的残留副本
* @param ProjectTask $item
*/
private function createLoopTask(ProjectTask $item)
{
AbstractModel::transaction(function () use ($item) {
// 锁定并复核原任务:已被并发或上一轮处理(loop_at 已变化/清空)则跳过
$source = ProjectTask::whereKey($item->id)->lockForUpdate()->first();
if (!$source
|| $source->parent_id > 0
|| !$source->loop_at
|| Carbon::parse($source->loop_at)->toDateTimeString() !== Carbon::parse($item->loop_at)->toDateTimeString()) {
return;
}
$task = $source->copyTask();
ProjectTaskHandoffRecord::track($task, 'recurring', function () use ($task, $source) {
// 工作流
$projectFlow = ProjectFlow::whereProjectId($task->project_id)->orderByDesc('id')->first();
if ($projectFlow) {
$projectFlowItem = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
// 赋一个开始状态
foreach ($projectFlowItem as $flowItem) {
if ($flowItem->status == 'start') {
$task->flow_item_id = $flowItem->id;
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
if ($flowItem->userids) {
$userids = array_values(array_unique($flowItem->userids));
foreach ($userids as $uid) {
ProjectTaskUser::updateInsert([
'task_id' => $task->id,
'userid' => $uid,
], [
'project_id' => $task->project_id,
'task_pid' => $task->id,
'owner' => 1,
]);
}
}
break;
}
}
}
// 新任务时间、周期
if ($task->start_at) {
$diffSecond = (int)Carbon::parse($task->start_at)->diffInSeconds(Carbon::parse($task->end_at), true);
$task->start_at = Carbon::parse($task->loop_at);
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
}
// 处理子任务
$source->copySubTasks($task, [
'reset_complete' => true,
'sync_time' => true,
]);
// 刷新新任务的下个周期,失败则抛出使整体回滚(否则副本会带着旧 loop_at 被再次复制)
if (!$task->refreshLoop(true)) {
throw new ApiException('重复周期设置无效');
}
$task->addLog("创建任务来自周期任务ID:{$source->id}", [], $task->userid);
});
// 清空旧周期
$source->loop = '';
$source->loop_at = null;
$source->save();
$source->addLog("已创建新的周期任务ID:{$task->id},此任务关闭周期", [], $task->userid);
});
}
public function end()
{

View File

@ -1081,3 +1081,4 @@ WebDAV 未启用或你没有使用权限
流转设置选项无效
指派成功
任务负责人最多不能超过10个
重复周期设置无效

View File

@ -16,9 +16,9 @@ related_pages: [task_detail, calendar]
prerequisites: []
negative:
- DooTask 任务没有 time_estimate / time_log 字段,不能记录工时
- 不支持只设开始时间不设结束时间(end_at 是计算到期与循环周期的依赖)
- 不支持只设开始时间不设结束时间(end_at 是计算到期的依赖)
- 跨时区显示按用户所在时区,但服务端统一存 UTC
last_verified: v1.7.90
last_verified: v1.9.36
---
# 任务计划时间(start_at / end_at)
@ -28,7 +28,7 @@ DooTask 任务用两个字段描述时间:
- `start_at`:计划开始时间
- `end_at`:计划截止时间(俗称 deadline)
两者都可空。**填入 end_at 才能启用循环重复、到期提醒、日历展示。**
两者都可空。**填入 end_at 才能启用到期提醒、日历展示。** 重复循环不要求填 end_at。
## 在哪里能看到
- 任务详情页 → 「计划时间」字段
@ -42,12 +42,11 @@ DooTask 任务用两个字段描述时间:
- 全天任务则 end_at 自动设为当天 23:59:59
## 与其他字段联动
- **重复循环**([[task.recurring.howto]])依赖 end_at 计算下一次 loop_at
- **重复循环**([[task.recurring.howto]])以 start_at 为基准计算下一次 loop_at,不依赖 end_at;新任务沿用原任务的时长
- **到期提醒**(ProjectTaskPushLog)按 end_at 触发推送(默认到期日推送、超期再推送)
- **日历视图** 只显示有 start_at 或 end_at 的任务
- **完成时间**(complete_at)与计划时间无关,是否超期由 complete_at vs end_at 比较
## 不支持
- 不能只设 start_at 不设 end_at 来启用循环
- 不能录工时(time_estimate / time_log 字段不存在)
- 修改 end_at 不会回填已生成的循环任务实例

View File

@ -19,8 +19,8 @@ prerequisites:
negative:
- 父任务完成不会自动完成所有子任务,需要手动或 [[task.subtask.howto.create]] 后逐个处理
- 完成不等于归档,归档要走 [[task.archive.howto]]
- 重复任务在完成的瞬间生成下一份,详见 [[task.recurring.howto]]
last_verified: v1.7.90
- 完成任务不会触发生成下一份重复任务,重复任务按 loop_at 时间点自动生成,详见 [[task.recurring.howto]]
last_verified: v1.9.36
---
# 标记任务完成 / 取消完成
@ -37,7 +37,6 @@ last_verified: v1.7.90
1. 点击复选框 / 完成按钮
2. 任务 `complete_at` 写入当前时间,整个卡片显示删除线 / 灰化
3. 项目动态记一条「X 完成了任务 Y」日志
4. 若该任务带 `loop`(循环),同时生成下一份新任务
## 取消完成
1. 在已完成任务上再次点击复选框

View File

@ -11,22 +11,25 @@ aliases:
- 每天的任务
- 周报任务自动生成
- 怎么让任务自动重复
related_tools: [create_task, update_task, complete_task]
- 重复周期
related_tools: [create_task, update_task]
related_pages: [task_detail]
prerequisites:
- 任务已设置 end_at(截止时间),否则无法计算下一周期
- 只能在顶级任务上设置,子任务不支持
negative:
- 重复任务不能在子任务上独立设置(subtask 不支持 loop)
- 重复任务的下一份在「上一份被标记完成时」生成,不是按日历预生成
- 下一份不是在任务被标记完成时生成,而是到 loop_at 时间点由系统定时任务自动生成,与原任务是否完成无关
- 只在 loop_at 之后的 10 分钟内生成;服务停机超过 10 分钟错过的周期不会补生成
- 自定义天数必须是正整数,0 或非数字会被拒绝
- 修改循环周期不会回填已生成的实例
- 所属项目已被删除或已归档时,不再自动生成新的周期任务
last_verified: v1.8.64
last_verified: v1.9.36
---
# 重复 / 循环任务
## 是什么
DooTask 任务支持循环重复(字段 `loop` + `loop_at`),常用于「每日站会」「每周周报」类型任务。每次任务被标记完成时,系统按 `loop` 自动生成下一份新任务,复制原任务字段并按 `loop_at` 推下一次计划时间。
DooTask 任务支持循环重复(字段 `loop` 重复周期 + `loop_at` 下个周期时间),常用于「每日站会」「每周周报」类型任务。系统定时任务每分钟检查一次,到达 `loop_at` 时自动创建下一份新任务,原任务是否已完成不影响生成。
## 支持的周期
- `day` 每天
@ -35,21 +38,22 @@ DooTask 任务支持循环重复(字段 `loop` + `loop_at`),常用于「
- `twoweeks` 双周
- `month` 每月
- `year` 每年
- 自定义天数(输入数字,如 3 表示每 3 天)
- 自定义天数(输入正整数,如 3 表示每 3 天;0 或非数字会被拒绝,提示「重复周期选择错误」)
- `never` 不重复(默认)
## 入口
- 桌面端:任务详情页 → 计划时间区 → 「循环」下拉
- 必须先填 end_at(截止时间)才能启用循环
- 桌面端:任务详情页 →「重复周期」项(未设置时在添加项菜单里选「重复周期」),选预设周期或「自定义」
- 设置后鼠标悬停周期文字可看到「下个周期」时间
- 不要求填截止时间:以开始时间为基准计算,未设开始时间则以当天 0 点为基准
## 工作原理
1. 标记 [[task.complete.howto]] 完成当前任务
2. 服务端基于 `loop` 与当前 `end_at` 计算下一个 `loop_at`
3. 自动创建新任务并复制:标题、描述、负责人、协作者、标签、优先级、可见性、子任务
4. 新任务的状态为未完成,按新计划时间出现在日历视图
1. 设置或修改周期、计划时间时,服务端按 `loop` 和开始时间算出 `loop_at`
2. 到 `loop_at` 后,系统创建新任务,复制:标题、描述、负责人和协助人员、附件、优先级、可见性、子任务
3. 新任务开始时间等于原任务的 `loop_at`,时长与原任务相同;状态为未完成(项目有工作流则回到起始状态),子任务也重置为未完成
4. 新任务自己算出下一个 `loop_at`;原任务的周期被关闭(`loop` 清空),并记一条日志
5. 创建失败时整体回滚,不会留下半成品,任务日志记「生成重复任务失败:原因」,到期后 10 分钟内每分钟重试
## 不支持
- 不能为同一任务并行启用多个周期
- 循环不复制附件
- 子任务不能独立设置循环
- 所属项目已被删除或已归档的任务不再自动生成新周期任务;这类残留任务可在任务详情顶部横幅点「删除任务」清理