fix: 采集失败不降级有数据分行+任务领取计数对齐实际分发行数
- 采集失败时按 latest_likes 分流:无数据才标 failed,已有历史数据保持 success 并注明沿用上次数据,collection_updated_at 不动;run 行照实记失败 - 种子 seedIfEmpty 末尾统一对账 claimed_quantity,杜绝演示数据口径漂移 - 新增 mysql/0021 对账迁移,修复 0020 物理合并遗留的生产计数漂移 - 运营端展示全面改读实时 dist_total(任务卡双面板/项目树/概览/进度), project-service 查询补 dist_total 子查询 - 走查实证:卡片 7/18 vs 详情已领取 2 两处口径打架;保存采集计划补采 失败会把历史成功行改写为 failed
This commit is contained in:
@@ -2927,6 +2927,7 @@ type ProjectTaskItem = {
|
|||||||
task_chat_id: string;
|
task_chat_id: string;
|
||||||
recovery_enabled: number;
|
recovery_enabled: number;
|
||||||
recovery_due_at: string;
|
recovery_due_at: string;
|
||||||
|
dist_total: number;
|
||||||
recovery_submitted: number;
|
recovery_submitted: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3025,7 +3026,10 @@ function ProjectSection({
|
|||||||
}, [project.id, setToast]);
|
}, [project.id, setToast]);
|
||||||
|
|
||||||
const tasksSignature = tasks
|
const tasksSignature = tasks
|
||||||
.map((task) => `${task.id}:${task.claimed_quantity}:${task.status}`)
|
.map(
|
||||||
|
(task) =>
|
||||||
|
`${task.id}:${task.dist_total}:${task.dist_submitted}:${task.status}`,
|
||||||
|
)
|
||||||
.join("|");
|
.join("|");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3266,11 +3270,11 @@ function ProjectSection({
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{task.claimed_quantity} / {task.quantity}
|
{task.dist_total} / {task.quantity}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{Number(task.recovery_enabled) === 1
|
{Number(task.recovery_enabled) === 1
|
||||||
? `已收 ${task.recovery_submitted} / ${task.claimed_quantity}`
|
? `已收 ${task.recovery_submitted} / ${task.dist_total}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -3937,7 +3941,7 @@ function OverviewPage({
|
|||||||
{
|
{
|
||||||
label: "正在分发任务",
|
label: "正在分发任务",
|
||||||
value: stats.activeTasks,
|
value: stats.activeTasks,
|
||||||
hint: `${tasks.reduce((sum, task) => sum + task.claimed_quantity, 0)} 篇已被领取`,
|
hint: `${tasks.reduce((sum, task) => sum + (task.dist_total ?? 0), 0)} 篇已被领取`,
|
||||||
tone: "green",
|
tone: "green",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4006,7 +4010,7 @@ function OverviewPage({
|
|||||||
<i />
|
<i />
|
||||||
<div className="workflow-step active">
|
<div className="workflow-step active">
|
||||||
<span>02</span>
|
<span>02</span>
|
||||||
<div><strong>KOC领取</strong><small>{tasks.reduce((sum, task) => sum + task.claimed_quantity, 0)} 篇</small></div>
|
<div><strong>KOC领取</strong><small>{tasks.reduce((sum, task) => sum + (task.dist_total ?? 0), 0)} 篇</small></div>
|
||||||
</div>
|
</div>
|
||||||
<i />
|
<i />
|
||||||
<div className="workflow-step">
|
<div className="workflow-step">
|
||||||
@@ -4036,7 +4040,7 @@ function OverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="task-mini-list">
|
<div className="task-mini-list">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
|
const progress = Math.round(((task.dist_total ?? 0) / task.quantity) * 100);
|
||||||
return (
|
return (
|
||||||
<article key={task.id}>
|
<article key={task.id}>
|
||||||
<div className="task-mini-top">
|
<div className="task-mini-top">
|
||||||
@@ -4053,7 +4057,7 @@ function OverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
||||||
<div className="task-mini-meta">
|
<div className="task-mini-meta">
|
||||||
<span>{task.claimed_quantity}/{task.quantity} 篇已领取</span>
|
<span>{task.dist_total ?? 0}/{task.quantity} 篇已领取</span>
|
||||||
<span>{formatDate(task.due_at)}截止</span>
|
<span>{formatDate(task.due_at)}截止</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -4400,7 +4404,7 @@ function TaskRowCard({
|
|||||||
<span className="panel-count">{task.quantity} 篇内容</span>
|
<span className="panel-count">{task.quantity} 篇内容</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
{panelMetric(task.claimed_quantity, task.quantity, "已领取", "publish")}
|
{panelMetric(task.dist_total ?? 0, task.quantity, "已领取", "publish")}
|
||||||
{panelChat(task, "任务群")}
|
{panelChat(task, "任务群")}
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-foot">
|
<div className="panel-foot">
|
||||||
@@ -4416,11 +4420,11 @@ function TaskRowCard({
|
|||||||
<span className="panel-count">
|
<span className="panel-count">
|
||||||
{task.recovery_due_at && task.recovery_due_at !== task.due_at
|
{task.recovery_due_at && task.recovery_due_at !== task.due_at
|
||||||
? `截止 ${formatDate(task.recovery_due_at)}`
|
? `截止 ${formatDate(task.recovery_due_at)}`
|
||||||
: `${task.claimed_quantity} 份截图`}
|
: `${task.dist_total ?? 0} 份截图`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
{panelMetric(task.dist_submitted ?? 0, task.claimed_quantity, "已收取", "recovery")}
|
{panelMetric(task.dist_submitted ?? 0, task.dist_total ?? 0, "已收取", "recovery")}
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-foot">
|
<div className="panel-foot">
|
||||||
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
|
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
|
||||||
|
|||||||
@@ -355,14 +355,25 @@ export async function collectDistributionMetrics(
|
|||||||
.bind(message, run.id),
|
.bind(message, run.id),
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
// 已有历史数据(此前采集成功过)的分行失败时不降级为 failed:
|
||||||
|
// 保留 success 状态与数据时间,仅在描述里说明本次刷新失败。
|
||||||
`UPDATE distributions
|
`UPDATE distributions
|
||||||
SET collection_status = 'failed',
|
SET collection_status = CASE
|
||||||
collection_status_description = ?,
|
WHEN latest_likes IS NULL THEN 'failed'
|
||||||
collection_updated_at = CURRENT_TIMESTAMP,
|
ELSE 'success'
|
||||||
|
END,
|
||||||
|
collection_status_description = CASE
|
||||||
|
WHEN latest_likes IS NULL THEN ?
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
|
collection_updated_at = CASE
|
||||||
|
WHEN latest_likes IS NULL THEN CURRENT_TIMESTAMP
|
||||||
|
ELSE collection_updated_at
|
||||||
|
END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.bind(message, distributionId),
|
.bind(message, `${message},沿用上次采集数据`, distributionId),
|
||||||
]);
|
]);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -853,6 +853,18 @@ export async function seedIfEmpty() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务卡与详情的「已领取」以 claimed_quantity 为口径,领取/释放路径会
|
||||||
|
// 增减它;种子阶段的手写数字与实际分发行数容易漂移,最后统一对账。
|
||||||
|
batch.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET claimed_quantity = (
|
||||||
|
SELECT COUNT(*) FROM distributions d WHERE d.task_id = tasks.id
|
||||||
|
)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await db.batch(batch);
|
await db.batch(batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type ProjectTaskRow = {
|
|||||||
task_chat_id: string;
|
task_chat_id: string;
|
||||||
recovery_enabled: number;
|
recovery_enabled: number;
|
||||||
recovery_due_at: string;
|
recovery_due_at: string;
|
||||||
|
dist_total: number;
|
||||||
recovery_submitted: number;
|
recovery_submitted: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,6 +288,7 @@ export async function getProjectDetail(
|
|||||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
task_type, platform, content_format, share_token, owner_userid, task_chat_id,
|
task_type, platform, content_format, share_token, owner_userid, task_chat_id,
|
||||||
recovery_enabled, recovery_due_at,
|
recovery_enabled, recovery_due_at,
|
||||||
|
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total,
|
||||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted
|
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted
|
||||||
FROM tasks t WHERE t.project_id = ?
|
FROM tasks t WHERE t.project_id = ?
|
||||||
ORDER BY t.created_at DESC`,
|
ORDER BY t.created_at DESC`,
|
||||||
|
|||||||
13
mysql/0021_reconcile_claimed_quantity.sql
Normal file
13
mysql/0021_reconcile_claimed_quantity.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- 校准任务领取计数:0020 物理合并删除了伴生回收任务的分发/领取行、并搬运了
|
||||||
|
-- 部分回收到父任务分发行,但没有同步父任务 claimed_quantity,任务卡「已领取」
|
||||||
|
-- 会与实际分发行数漂移。领取(+1)/释放(-1) 路径共同维护的不变式是:
|
||||||
|
-- content_publish 任务的 claimed_quantity = 当前分发行数,此处按此对账。
|
||||||
|
UPDATE tasks
|
||||||
|
SET claimed_quantity = (
|
||||||
|
SELECT COUNT(*) FROM distributions d
|
||||||
|
WHERE d.task_id = tasks.id
|
||||||
|
)
|
||||||
|
WHERE claimed_quantity <> (
|
||||||
|
SELECT COUNT(*) FROM distributions d
|
||||||
|
WHERE d.task_id = tasks.id
|
||||||
|
);
|
||||||
53
tests/claimed-quantity.test.mjs
Normal file
53
tests/claimed-quantity.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
// claimed_quantity 口径:领取(+1)/释放(-1) 维护的不变式是
|
||||||
|
// claimed_quantity == 当前分发行数。种子与 0020 迁移都可能造成漂移,
|
||||||
|
// 这里钉住三道防线:种子末尾对账、0021 生产对账、UI 一律读实时 dist_total。
|
||||||
|
|
||||||
|
test("seed reconciles claimed_quantity with actual distribution rows", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../lib/mvp-db.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const reconcile = source.indexOf(
|
||||||
|
"SET claimed_quantity = (\n SELECT COUNT(*) FROM distributions d WHERE d.task_id = tasks.id",
|
||||||
|
);
|
||||||
|
assert.ok(reconcile > 0, "种子阶段缺少 claimed_quantity 对账 UPDATE");
|
||||||
|
const lastDistributionInsert = source.lastIndexOf(
|
||||||
|
"INSERT INTO distributions",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
reconcile > lastDistributionInsert,
|
||||||
|
"对账必须发生在所有分发 INSERT 之后",
|
||||||
|
);
|
||||||
|
const batchEnd = source.indexOf("await db.batch(batch);", reconcile);
|
||||||
|
assert.ok(batchEnd > reconcile, "对账语句必须在 seedIfEmpty 的 batch 内执行");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migration 0021 recalculates drifted claimed_quantity in production", async () => {
|
||||||
|
const migration = await readFile(
|
||||||
|
new URL("../mysql/0021_reconcile_claimed_quantity.sql", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(migration, /UPDATE tasks\s+SET claimed_quantity = \(\s+SELECT COUNT\(\*\) FROM distributions d\s+WHERE d\.task_id = tasks\.id\s+\)/);
|
||||||
|
assert.match(migration, /WHERE claimed_quantity <> \(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin surfaces read the live distribution count, not the counter", async () => {
|
||||||
|
const [adminApp, projectService] = await Promise.all([
|
||||||
|
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/project-service.ts", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 任务卡双面板 + 概览统计
|
||||||
|
assert.match(adminApp, /panelMetric\(task\.dist_total \?\? 0, task\.quantity, "已领取"/);
|
||||||
|
assert.match(adminApp, /panelMetric\(task\.dist_submitted \?\? 0, task\.dist_total \?\? 0, "已收取"/);
|
||||||
|
assert.match(adminApp, /sum \+ \(task\.dist_total \?\? 0\)/);
|
||||||
|
// 项目树明细同样用实时口径
|
||||||
|
assert.match(projectService, /AS dist_total/);
|
||||||
|
assert.match(adminApp, /\{task\.dist_total\} \/ \{task\.quantity\}/);
|
||||||
|
// 展示层不再读反规范化计数器
|
||||||
|
assert.doesNotMatch(adminApp, /task\.claimed_quantity/);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { isCollectionScheduleDue } from "../lib/collection-service.ts";
|
import { isCollectionScheduleDue } from "../lib/collection-service.ts";
|
||||||
import { sortWithNullsLast } from "../lib/sort-utils.ts";
|
import { sortWithNullsLast } from "../lib/sort-utils.ts";
|
||||||
@@ -56,3 +57,24 @@ test("sorts both directions while keeping missing values last", () => {
|
|||||||
["high", "middle", "low", "missing-a", "missing-b"],
|
["high", "middle", "low", "missing-a", "missing-b"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a failed collection keeps historically successful rows on success", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../lib/collection-service.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 失败写入必须按 latest_likes 分流:无数据 → failed;
|
||||||
|
// 已有历史数据 → 保持 success,仅描述里说明沿用上次数据。
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/collection_status = CASE\s+WHEN latest_likes IS NULL THEN 'failed'\s+ELSE 'success'\s+END/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/ELSE \?\s+END,\s+collection_updated_at = CASE\s+WHEN latest_likes IS NULL THEN CURRENT_TIMESTAMP\s+ELSE collection_updated_at/,
|
||||||
|
);
|
||||||
|
assert.match(source, /`\$\{message\},沿用上次采集数据`/);
|
||||||
|
// run 行照实记录失败,保留可观测性
|
||||||
|
assert.match(source, /UPDATE collection_runs\s+SET status = 'failed'/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user