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:
ABAPPLO
2026-09-24 15:52:45 +08:00
parent ab78b187c0
commit e2a93e22cf
29 changed files with 2219 additions and 2195 deletions

View File

@@ -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 () => {