feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete - 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份) - 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止 - 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段 - admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度 - 测试 309 全绿 + Playwright E2E 20/20
This commit is contained in:
@@ -26,7 +26,8 @@ const overdueRows = [
|
||||
task_id: "task-A",
|
||||
task_name: "超期任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-27",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "proj-1",
|
||||
@@ -38,7 +39,8 @@ const overdueRows = [
|
||||
task_id: "task-A",
|
||||
task_name: "超期任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-27",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "proj-1",
|
||||
@@ -50,7 +52,8 @@ const overdueRows = [
|
||||
task_id: "task-B",
|
||||
task_name: "无项目任务B",
|
||||
due_at: "2026-08-25",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 0,
|
||||
recovery_due_at: "",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -62,7 +65,6 @@ const overdueRows = [
|
||||
const releasableDistribution = {
|
||||
id: "d-1",
|
||||
task_id: "task-A",
|
||||
task_type: "content_publish",
|
||||
content_id: "c-1",
|
||||
content_title: "超期内容",
|
||||
partner_id: "p-1",
|
||||
@@ -73,13 +75,30 @@ const releasableDistribution = {
|
||||
result_submitted_at: null,
|
||||
};
|
||||
|
||||
function filterOverdue(rows, cutoff) {
|
||||
return rows.filter((row) => {
|
||||
if (!(row.due_at && row.due_at < cutoff)) return false;
|
||||
return row.task_type === "screenshot_collect"
|
||||
? !row.result_submitted_at
|
||||
: !row.publish_url;
|
||||
});
|
||||
// 双阶段超期判定:发布阶段看 due_at,回收阶段(已发布未收截图)看 recovery_due_at
|
||||
function filterOverdue(rows, cutoff, { phases = "all" } = {}) {
|
||||
const output = [];
|
||||
for (const row of rows) {
|
||||
if (!row.publish_url && row.due_at && row.due_at < cutoff) {
|
||||
output.push({ ...row, pending_phase: "publish", pending_due_at: row.due_at });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
phases === "all" &&
|
||||
row.recovery_enabled === 1 &&
|
||||
row.publish_url &&
|
||||
!row.result_submitted_at &&
|
||||
row.recovery_due_at &&
|
||||
row.recovery_due_at < cutoff
|
||||
) {
|
||||
output.push({
|
||||
...row,
|
||||
pending_phase: "recovery",
|
||||
pending_due_at: row.recovery_due_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||
@@ -95,11 +114,12 @@ function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
if (/FROM distributions d/.test(sql)) {
|
||||
return {
|
||||
results: filterOverdue(rows, params[0], {
|
||||
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -138,11 +158,12 @@ function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
if (/FROM distributions d/.test(sql)) {
|
||||
return {
|
||||
results: filterOverdue(rows, params[0], {
|
||||
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -250,38 +271,44 @@ test("runOverdueEscalation no-ops without overdue rows", async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => {
|
||||
test("runOverdueEscalation 按发布/回收两阶段分别出文案", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "d-s1",
|
||||
// 回收阶段超期:已发布、未收截图、recovery_due_at 已过
|
||||
id: "d-r1",
|
||||
task_id: "task-S",
|
||||
task_name: "截图任务S",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "screenshot_collect",
|
||||
publish_url: null,
|
||||
task_name: "回收任务S",
|
||||
due_at: "2026-08-10",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-20",
|
||||
publish_url: "https://www.xiaohongshu.com/note/s1",
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
partner_id: "p-1",
|
||||
partner_name: "合作方甲",
|
||||
},
|
||||
{
|
||||
id: "d-s2",
|
||||
// 已提交截图:两阶段都不超期
|
||||
id: "d-r2",
|
||||
task_id: "task-S",
|
||||
task_name: "截图任务S",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "screenshot_collect",
|
||||
publish_url: null,
|
||||
task_name: "回收任务S",
|
||||
due_at: "2026-08-10",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-20",
|
||||
publish_url: "https://www.xiaohongshu.com/note/s2",
|
||||
result_submitted_at: "2026-08-21 09:00:00",
|
||||
project_id: "",
|
||||
partner_id: "p-2",
|
||||
partner_name: "合作方乙",
|
||||
},
|
||||
{
|
||||
// 已发布且回收未到期:不算超期
|
||||
id: "d-c1",
|
||||
task_id: "task-A",
|
||||
task_name: "内容任务A",
|
||||
task_name: "发布任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-09-10",
|
||||
publish_url: "https://www.xiaohongshu.com/note/1",
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -289,11 +316,13 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
|
||||
partner_name: "合作方丙",
|
||||
},
|
||||
{
|
||||
// 发布阶段超期:未回填链接
|
||||
id: "d-c2",
|
||||
task_id: "task-A",
|
||||
task_name: "内容任务A",
|
||||
task_name: "发布任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-09-10",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -308,15 +337,14 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
|
||||
wecomFetch(calls),
|
||||
NOW,
|
||||
);
|
||||
// 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期
|
||||
assert.equal(summary.overdueTasks, 2);
|
||||
assert.equal(summary.overdueTasks, 2, "任务×阶段 各一条");
|
||||
assert.equal(summary.overdueDistributions, 2);
|
||||
const robotBodies = calls
|
||||
.filter((call) => call.url.host === "qyapi.example.com")
|
||||
.map((call) => JSON.parse(String(call.init.body)));
|
||||
assert.equal(robotBodies.length, 1);
|
||||
assert.match(robotBodies[0].text.content, /截图任务S(截止 2026-08-20):1 条/);
|
||||
assert.match(robotBodies[0].text.content, /内容任务A(截止 2026-08-20):1 条/);
|
||||
assert.match(robotBodies[0].text.content, /回收任务S(截止 2026-08-20):1 条截图超期未提交/);
|
||||
assert.match(robotBodies[0].text.content, /发布任务A(截止 2026-08-20):1 条超期未发布/);
|
||||
});
|
||||
|
||||
test("runOverdueAutoRelease is disabled without env switch", async () => {
|
||||
|
||||
Reference in New Issue
Block a user