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

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { updateTask } from "../lib/task-service.ts";
import { updateTask, enableTaskRecovery } from "../lib/task-service.ts";
function fakeDb({ task, contents = [] }) {
const tasks = task ? [task] : [];
@@ -14,21 +14,40 @@ function fakeDb({ task, contents = [] }) {
if (/FROM tasks WHERE id = \?/.test(sql)) {
return tasks.find((row) => row.id === values[0]) ?? null;
}
if (/SELECT image_assets FROM contents/.test(sql)) {
const sorted = rows
.filter((row) => row.task_id === values[0])
.sort((a, b) => a.source_row - b.source_row);
return sorted[0] ? { image_assets: sorted[0].image_assets } : null;
}
return null;
},
async run() {
const normalized = sql.trim();
if (/^UPDATE tasks/.test(normalized)) {
const [name, brand, due_at, project_id, platform, content_format, quantity, id] =
if (/^UPDATE tasks\s+SET recovery_enabled = 1/.test(normalized)) {
const [recovery_due_at, recovery_instructions, recovery_example_images, id] =
values;
const row = tasks.find((item) => item.id === id);
if (!row) return { meta: { changes: 0 } };
Object.assign(row, {
recovery_enabled: 1,
recovery_due_at,
recovery_instructions,
recovery_example_images,
});
return { meta: { changes: 1 } };
}
if (/^UPDATE tasks/.test(normalized)) {
const [
name,
brand,
due_at,
project_id,
platform,
content_format,
quantity,
recovery_enabled,
recovery_due_at,
recovery_instructions,
recovery_example_images,
id,
] = values;
const row = tasks.find((item) => item.id === id);
if (!row) return { meta: { changes: 0 } };
Object.assign(row, {
name,
brand,
@@ -37,50 +56,13 @@ function fakeDb({ task, contents = [] }) {
platform,
content_format,
quantity,
recovery_enabled,
recovery_due_at,
recovery_instructions,
recovery_example_images,
});
return { meta: { changes: 1 } };
}
if (/^INSERT INTO contents/.test(normalized)) {
const [id, task_id, title, body, image_assets, source_row] = values;
rows.push({
id,
task_id,
title,
body,
image_assets,
status: "available",
source_row,
});
return { meta: { changes: 1 } };
}
if (/^DELETE FROM contents/.test(normalized)) {
const [taskId, limit] = values;
const available = rows
.filter((row) => row.task_id === taskId && row.status === "available")
.sort((a, b) => b.source_row - a.source_row);
const doomed = new Set(available.slice(0, limit).map((row) => row.id));
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (doomed.has(rows[index].id)) rows.splice(index, 1);
}
return { meta: { changes: doomed.size } };
}
if (/^UPDATE contents SET title/.test(normalized)) {
const [title, body, taskId] = values;
for (const row of rows) {
if (row.task_id === taskId) {
row.title = title;
row.body = body;
}
}
return { meta: { changes: rows.length } };
}
if (/^UPDATE contents SET image_assets/.test(normalized)) {
const [image_assets, taskId] = values;
for (const row of rows) {
if (row.task_id === taskId) row.image_assets = image_assets;
}
return { meta: { changes: rows.length } };
}
return { meta: { changes: 0 } };
},
async all() {
@@ -101,234 +83,78 @@ function fakeDb({ task, contents = [] }) {
return { db, tasks, contents: rows };
}
function screenshotTask(overrides = {}) {
function publishTask(overrides = {}) {
return {
id: "task-shot-1",
task_type: "screenshot_collect",
quantity: 3,
claimed_quantity: 0,
platform: "小红书",
content_format: "image_text",
id: "task-pub-1",
task_type: "content_publish",
quantity: 18,
claimed_quantity: 2,
platform: "抖音",
content_format: "video",
name: "旧名称",
brand: "旧品牌",
due_at: "2026-08-01",
project_id: "",
status: "active",
recovery_enabled: 1,
recovery_due_at: "2026-08-10",
recovery_instructions: "请提交发布后的笔记截图",
recovery_example_images: "[]",
...overrides,
};
}
function screenshotContents(quantity, overrides = {}) {
function publishContents(quantity) {
return Array.from({ length: quantity }, (_, index) => ({
id: `content-${index + 1}`,
task_id: "task-shot-1",
title: overrides.title ?? "旧关键词",
body: overrides.body ?? "旧说明",
image_assets: overrides.imageAssets ?? "[]",
status: index < (overrides.claimed ?? 0) ? "claimed" : "available",
task_id: "task-pub-1",
title: "笔记标题",
body: "笔记正文",
image_assets: "[]",
status: index < 2 ? "claimed" : "available",
source_row: index + 1,
}));
}
test("updateTask rewrites basic fields and syncs keyword/instructions on all rows", async () => {
test("updateTask rewrites basic fields, quantity and contents stay untouched", async () => {
const { db, tasks, contents } = fakeDb({
task: screenshotTask(),
contents: screenshotContents(3),
task: publishTask(),
contents: publishContents(5),
});
const result = await updateTask(
{
taskId: "task-shot-1",
name: "新名称",
brand: "新品牌",
dueAt: "2026-09-30",
projectId: "proj-1",
keyword: "新关键词",
instructions: "新说明",
quantity: 3,
},
db,
);
assert.equal(result.quantity, 3);
assert.equal(tasks[0].name, "新名称");
assert.equal(tasks[0].brand, "新品牌");
assert.equal(tasks[0].due_at, "2026-09-30");
assert.equal(tasks[0].project_id, "proj-1");
assert.equal(contents.length, 3);
for (const row of contents) {
assert.equal(row.title, "新关键词");
assert.equal(row.body, "新说明");
}
});
test("updateTask grows placeholder contents when quantity increases", async () => {
const imageAssets = JSON.stringify([{ index: 1, key: "task-assets/a.png", width: null, height: null }]);
const { db, tasks, contents } = fakeDb({
task: screenshotTask(),
contents: screenshotContents(3, { imageAssets }),
});
await updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 5,
},
db,
);
assert.equal(tasks[0].quantity, 5);
assert.equal(contents.length, 5);
const added = contents.filter((row) => row.source_row > 3);
assert.equal(added.length, 2);
for (const row of added) {
assert.equal(row.status, "available");
assert.equal(row.image_assets, imageAssets, "new rows copy example image");
assert.equal(row.title, "k");
}
});
test("updateTask shrinks only available tail rows when quantity decreases", async () => {
const { db, tasks, contents } = fakeDb({
task: screenshotTask({ quantity: 5, claimed_quantity: 2 }),
contents: screenshotContents(5, { claimed: 2 }),
});
await updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 3,
},
db,
);
assert.equal(tasks[0].quantity, 3);
assert.equal(contents.length, 3);
const claimedRows = contents.filter((row) => row.status === "claimed");
assert.equal(claimedRows.length, 2, "claimed rows preserved");
assert.ok(
contents.every((row) => row.source_row <= 3),
"tail rows removed",
);
});
test("updateTask rejects quantity below claimed_quantity", async () => {
const { db } = fakeDb({
task: screenshotTask({ quantity: 5, claimed_quantity: 2 }),
contents: screenshotContents(5, { claimed: 2 }),
});
await assert.rejects(
() =>
updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 1,
},
db,
),
/不能低于已领取的 2 份/,
);
});
test("updateTask validates screenshot quantity and keyword", async () => {
const { db } = fakeDb({ task: screenshotTask(), contents: screenshotContents(3) });
await assert.rejects(
() =>
updateTask(
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: "k", instructions: "i", quantity: 0 },
db,
),
/1—500/,
);
await assert.rejects(
() =>
updateTask(
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: " ", instructions: "i", quantity: 3 },
db,
),
/请补全搜索关键词和任务说明/,
);
});
test("updateTask replaces example image across contents when re-uploaded", async () => {
const { db, contents } = fakeDb({
task: screenshotTask(),
contents: screenshotContents(3),
});
await updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 3,
exampleImageKey: "task-assets/new.png",
},
db,
);
for (const row of contents) {
assert.equal(JSON.parse(row.image_assets)[0].key, "task-assets/new.png");
}
});
test("updateTask ignores quantity and keyword for content_publish tasks", async () => {
const { db, tasks, contents } = fakeDb({
task: screenshotTask({
id: "task-pub-1",
task_type: "content_publish",
quantity: 18,
platform: "抖音",
content_format: "video",
}),
contents: [
{
id: "content-pub-1",
task_id: "task-pub-1",
title: "笔记标题",
body: "笔记正文",
image_assets: "[]",
status: "available",
source_row: 1,
},
],
});
await updateTask(
{
taskId: "task-pub-1",
name: "新名称",
brand: "新品牌",
dueAt: "2026-09-30",
projectId: "proj-2",
quantity: 99,
keyword: "ignored",
instructions: "ignored",
projectId: "proj-1",
},
db,
);
assert.equal(tasks[0].quantity, 18, "feishu row count untouched");
assert.equal(tasks[0].platform, "抖音", "platform kept when not provided");
assert.equal(tasks[0].content_format, "video");
assert.equal(contents[0].title, "笔记标题", "contents untouched");
assert.equal(result.quantity, 18);
assert.equal(result.claimedQuantity, 2);
assert.equal(tasks[0].name, "新名称");
assert.equal(tasks[0].brand, "新品牌");
assert.equal(tasks[0].due_at, "2026-09-30");
assert.equal(tasks[0].project_id, "proj-1");
assert.equal(tasks[0].quantity, 18, "quantity 由飞书行数决定,不可编辑");
assert.equal(contents.length, 5, "contents 不随编辑变动");
assert.equal(contents[0].title, "笔记标题");
});
test("updateTask applies provided platform/contentFormat for content_publish", async () => {
test("updateTask keeps platform/contentFormat when not provided", async () => {
const { db, tasks } = fakeDb({ task: publishTask() });
await updateTask(
{ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-09-30" },
db,
);
assert.equal(tasks[0].platform, "抖音");
assert.equal(tasks[0].content_format, "video");
});
test("updateTask applies provided platform/contentFormat", async () => {
const { db, tasks } = fakeDb({
task: screenshotTask({
id: "task-pub-1",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
}),
task: publishTask({ platform: "小红书", content_format: "image_text" }),
});
await updateTask(
{
@@ -345,22 +171,107 @@ test("updateTask applies provided platform/contentFormat for content_publish", a
assert.equal(tasks[0].content_format, "video");
});
test("updateTask rejects unknown tasks and invalid dates", async () => {
const { db } = fakeDb({ task: screenshotTask() });
test("updateTask updates recovery fields and can toggle recovery off", async () => {
const { db, tasks } = fakeDb({ task: publishTask() });
await updateTask(
{
taskId: "task-pub-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
recoveryEnabled: false,
recoveryDueAt: "2026-10-15",
recoveryInstructions: "新回收说明",
},
db,
);
assert.equal(tasks[0].recovery_enabled, 0);
assert.equal(tasks[0].recovery_due_at, "2026-10-15");
assert.equal(tasks[0].recovery_instructions, "新回收说明");
});
test("updateTask keeps row recovery values when input omits them", async () => {
const { db, tasks } = fakeDb({
task: publishTask({
recovery_enabled: 0,
recovery_due_at: "",
recovery_instructions: "",
}),
});
await updateTask(
{ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-09-30" },
db,
);
assert.equal(tasks[0].recovery_enabled, 0, "未传开关时保持关闭");
assert.equal(
tasks[0].recovery_due_at,
"2026-09-30",
"回收截止为空时兜底为发布截止",
);
});
test("updateTask replaces example image JSON when key provided", async () => {
const { db, tasks } = fakeDb({ task: publishTask() });
await updateTask(
{
taskId: "task-pub-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
recoveryExampleImageKey: "task-assets/new.png",
},
db,
);
assert.deepEqual(JSON.parse(tasks[0].recovery_example_images), [
{ index: 1, key: "task-assets/new.png", width: null, height: null },
]);
});
test("enableTaskRecovery 开通老任务回收:默认截止=发布截止、默认说明、保留示例图", async () => {
const { db, tasks } = fakeDb({
task: publishTask({
recovery_enabled: 0,
recovery_due_at: "",
recovery_instructions: "",
recovery_example_images: "[]",
}),
});
const result = await enableTaskRecovery({ taskId: "task-pub-1" }, db);
assert.equal(result.recoveryDueAt, "2026-08-01");
assert.equal(tasks[0].recovery_enabled, 1);
assert.equal(tasks[0].recovery_due_at, "2026-08-01");
assert.equal(tasks[0].recovery_instructions, "请提交发布后的笔记截图");
const keepDb = fakeDb({
task: publishTask({
recovery_example_images:
'[{"index":1,"key":"task-assets/keep.png","width":null,"height":null}]',
}),
});
await enableTaskRecovery(
{ taskId: "task-pub-1", recoveryDueAt: "2026-10-01", recoveryInstructions: "定制说明" },
keepDb.db,
);
assert.equal(keepDb.tasks[0].recovery_due_at, "2026-10-01");
assert.equal(keepDb.tasks[0].recovery_instructions, "定制说明");
assert.deepEqual(JSON.parse(keepDb.tasks[0].recovery_example_images), [
{ index: 1, key: "task-assets/keep.png", width: null, height: null },
]);
});
test("updateTask / enableTaskRecovery reject unknown tasks and invalid dates", async () => {
const { db } = fakeDb({ task: publishTask() });
await assert.rejects(
() =>
updateTask(
{ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" },
db,
),
updateTask({ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" }, db),
/任务不存在/,
);
await assert.rejects(
() =>
updateTask(
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-13-01" },
db,
),
updateTask({ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-13-01" }, db),
/截止日期/,
);
await assert.rejects(
() => enableTaskRecovery({ taskId: "missing" }, db),
/任务不存在/,
);
});