feat: add release controls and 09:00 collection

This commit is contained in:
巫凤萍
2026-08-12 11:43:26 +08:00
parent 1f1887c860
commit 1766a90cc1
14 changed files with 665 additions and 38 deletions

View File

@@ -0,0 +1,25 @@
function shanghaiDateFromTimestamp(timestamp: number) {
return new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
function shanghaiHourFromTimestamp(timestamp: number) {
return Number(
new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(11, 13),
);
}
export function isCollectionScheduleDue(
scheduledDate: string,
timestamp: number,
) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const currentHour = shanghaiHourFromTimestamp(timestamp);
return (
scheduledDate < currentDate ||
(scheduledDate === currentDate && currentHour >= 9)
);
}

View File

@@ -3,6 +3,9 @@ import {
type CollectionMcpConfig,
} from "./mcp-collection-client";
import { uid } from "./mvp-db";
import { isCollectionScheduleDue } from "./collection-schedule";
export { isCollectionScheduleDue } from "./collection-schedule";
type DistributionForCollection = {
id: string;
@@ -51,21 +54,11 @@ export function shanghaiDateFromTimestamp(timestamp: number) {
.slice(0, 10);
}
function shanghaiHourFromTimestamp(timestamp: number) {
return Number(
new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(11, 13),
);
}
function dueSchedules(
startDate: string,
days: number[],
timestamp: number,
) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const currentHour = shanghaiHourFromTimestamp(timestamp);
return days
.map((scheduleDay) => ({
scheduleDay,
@@ -77,8 +70,7 @@ function dueSchedules(
): value is { scheduleDay: number; scheduledDate: string } =>
Boolean(
value.scheduledDate &&
(value.scheduledDate < currentDate ||
(value.scheduledDate === currentDate && currentHour >= 10)),
isCollectionScheduleDue(value.scheduledDate, timestamp),
),
);
}
@@ -130,8 +122,8 @@ export async function createCollectionRunTasks(
distribution.id,
scheduledDate,
scheduleDay,
`${scheduledDate}T10:00:00+08:00`,
`等待第${scheduleDay}10:00自动采集`,
`${scheduledDate}T09:00:00+08:00`,
`等待第${scheduleDay}天 09:00自动采集`,
),
);
}
@@ -161,7 +153,7 @@ export async function collectDistributionMetrics(
if (!current) throw new Error("分发记录不存在");
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
const scheduledAt = `${scheduledDate}T10:00:00+08:00`;
const scheduledAt = `${scheduledDate}T09:00:00+08:00`;
const runId = uid("run");
await db
.prepare(
@@ -230,7 +222,7 @@ export async function collectDistributionMetrics(
const dayWeight = scheduleDay ?? 1;
const successDescription =
source === "automatic"
? `成功 · 第${dayWeight}10:00自动采集`
? `成功 · 第${dayWeight}天 09:00自动采集`
: source === "catchup"
? `成功 · 第${dayWeight}天自动追采`
: "成功 · 手动采集";

View File

@@ -0,0 +1,137 @@
type ReleasableDistribution = {
id: string;
task_id: string;
content_id: string;
content_title: string;
partner_id: string;
partner_name: string;
claim_id: string | null;
delegation_bundle_id: string | null;
publish_url: string | null;
};
export class DistributionReleaseError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "DistributionReleaseError";
this.status = status;
}
}
export function distributionReleaseBlockReason(input: {
publishUrl?: string | null;
resultSubmittedAt?: string | null;
}) {
if (input.publishUrl) return "已回填发布链接的笔记不能释放";
if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放";
return null;
}
export async function releaseUnfinishedDistribution(
db: D1Database,
distributionId: string,
) {
if (!distributionId) {
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
}
const distribution = await db
.prepare(
`SELECT
d.id,
d.task_id,
d.content_id,
c.title AS content_title,
d.partner_id,
p.name AS partner_name,
d.claim_id,
d.delegation_bundle_id,
d.publish_url
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN partners p ON p.id = d.partner_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<ReleasableDistribution>();
if (!distribution) {
throw new DistributionReleaseError("领取记录不存在或已被释放", 404);
}
const blocked = distributionReleaseBlockReason({
publishUrl: distribution.publish_url,
});
if (blocked) throw new DistributionReleaseError(blocked, 409);
const statements: D1PreparedStatement[] = [
db
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
.bind(distribution.id),
db.prepare("DELETE FROM distributions WHERE id = ?").bind(distribution.id),
db
.prepare("UPDATE contents SET status = 'available' WHERE id = ?")
.bind(distribution.content_id),
db
.prepare(
`UPDATE tasks
SET claimed_quantity = MAX(claimed_quantity - 1, 0)
WHERE id = ?`,
)
.bind(distribution.task_id),
db
.prepare(
`UPDATE partners
SET claimed_total = MAX(claimed_total - 1, 0)
WHERE id = ?`,
)
.bind(distribution.partner_id),
];
if (distribution.delegation_bundle_id) {
statements.push(
db
.prepare(
`UPDATE delegation_bundles
SET quantity = MAX(quantity - 1, 0),
status = CASE WHEN quantity <= 1 THEN 'revoked' ELSE status END,
revoked_at = CASE WHEN quantity <= 1 THEN CURRENT_TIMESTAMP ELSE revoked_at END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(distribution.delegation_bundle_id),
);
}
if (distribution.claim_id) {
statements.push(
db
.prepare(
`UPDATE claims
SET quantity = MAX(quantity - 1, 0)
WHERE id = ?`,
)
.bind(distribution.claim_id),
db
.prepare(
`DELETE FROM claims
WHERE id = ?
AND quantity <= 0
AND NOT EXISTS (
SELECT 1 FROM distributions WHERE claim_id = ?
)`,
)
.bind(distribution.claim_id, distribution.claim_id),
);
}
await db.batch(statements);
return {
distributionId: distribution.id,
taskId: distribution.task_id,
contentId: distribution.content_id,
contentTitle: distribution.content_title,
partnerName: distribution.partner_name,
};
}

View File

@@ -275,7 +275,7 @@ export async function setCollectionPlan(
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND COALESCE(publish_url, '') != ''`,
).bind(`已安排${normalizedDays.length}个采集日,每日10:00执行`, taskId),
).bind(`已安排${normalizedDays.length}个采集日每日09:00执行`, taskId),
]);
const created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
const catchup = await runDueScheduledCollections(

View File

@@ -127,7 +127,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
"collection_plan_set",
{
title: "设置自动采集计划",
description: "为任务设置开始日期及第1至第7天的自动采集日系统在北京时间10:00执行。",
description: "为任务设置开始日期及第1至第7天的自动采集日系统在北京时间09:00执行。",
inputSchema: z.object({
task_id: z.string().min(1),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),

26
lib/sort-utils.ts Normal file
View File

@@ -0,0 +1,26 @@
export type SortDirection = "asc" | "desc";
export function sortWithNullsLast<T>(
items: T[],
valueFor: (item: T) => number | null,
direction: SortDirection,
) {
return items
.map((item, index) => ({ item, index }))
.sort((left, right) => {
const leftValue = valueFor(left.item);
const rightValue = valueFor(right.item);
if (leftValue === null && rightValue === null) {
return left.index - right.index;
}
if (leftValue === null) return 1;
if (rightValue === null) return -1;
const compared = leftValue - rightValue;
return compared === 0
? left.index - right.index
: direction === "asc"
? compared
: -compared;
})
.map(({ item }) => item);
}