- {taskDistributions.map((item) => {
+ {sortedTaskDistributions.map((item) => {
const metrics = latestPublicMetrics(item);
const noteUrl = xhsPublishUrl(item.publish_url);
const hasMetrics = metrics.likes !== null;
@@ -1906,7 +2134,7 @@ function CollectionScheduleCard({
自动采集计划
选择开始日期与采集日
-
勾选第1天到第7天,所选日期均在北京时间10:00自动采集。
+
勾选第1天到第7天,所选日期均在北京时间09:00自动采集。
已选择 {days.length} 天
diff --git a/app/api/action/route.ts b/app/api/action/route.ts
index 078ea21..4e89cca 100644
--- a/app/api/action/route.ts
+++ b/app/api/action/route.ts
@@ -19,12 +19,17 @@ import {
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
+import { isManagerRequest } from "../../../lib/user-auth";
import {
FeishuSourceError,
readFeishuSource,
type FeishuBindings,
} from "../../../lib/feishu-client";
import { createDistributionTask } from "../../../lib/task-service";
+import {
+ DistributionReleaseError,
+ releaseUnfinishedDistribution,
+} from "../../../lib/distribution-release-service";
export const runtime = "edge";
@@ -122,6 +127,12 @@ export async function POST(request: Request) {
.bind(available.results.length, partnerId),
);
await db.batch(statements);
+ } else if (body.action === "release_distribution") {
+ if (!(await isManagerRequest(request))) return adminForbidden();
+ await releaseUnfinishedDistribution(
+ db,
+ String(body.distributionId ?? "").trim(),
+ );
} else if (body.action === "save_collection_schedule") {
const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim();
@@ -179,7 +190,7 @@ export async function POST(request: Request) {
AND publish_url != ''`,
)
.bind(
- `已安排${days.length}个采集日,每日10:00执行`,
+ `已安排${days.length}个采集日,每日09:00执行`,
taskId,
),
]);
@@ -322,7 +333,13 @@ export async function POST(request: Request) {
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
- { status: error instanceof FeishuSourceError ? error.status : 500 },
+ {
+ status:
+ error instanceof FeishuSourceError ||
+ error instanceof DistributionReleaseError
+ ? error.status
+ : 500,
+ },
);
}
}
diff --git a/app/globals.css b/app/globals.css
index d68ae4c..24e0784 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1888,6 +1888,41 @@ a {
white-space: nowrap;
}
+.recovery-sort-button {
+ display: inline-flex;
+ min-width: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 4px;
+ padding: 8px 0;
+ border: 0;
+ color: inherit;
+ background: transparent;
+ font: inherit;
+ cursor: pointer;
+}
+
+.recovery-sort-button b {
+ color: #aab2af;
+ font-size: 11px;
+ line-height: 1;
+}
+
+.recovery-sort-button:hover,
+.recovery-sort-button.active {
+ color: #168565;
+}
+
+.recovery-sort-button.active b {
+ color: #168565;
+}
+
+.recovery-sort-button:focus-visible {
+ border-radius: 4px;
+ outline: 2px solid rgba(22, 133, 101, 0.3);
+ outline-offset: 2px;
+}
+
.recovery-content {
max-width: 360px;
}
@@ -3041,6 +3076,81 @@ label small {
color: #b04b40;
}
+.eyebrow.warning {
+ color: #b06d24;
+}
+
+.release-distribution-button {
+ min-height: 30px;
+ padding: 0 11px;
+ border: 1px solid #efd9bd;
+ border-radius: 8px;
+ color: #95601f;
+ background: #fffaf2;
+ font-size: 9px;
+ font-weight: 650;
+ white-space: nowrap;
+}
+
+.release-distribution-button:hover {
+ border-color: #dfbd91;
+ background: #fff5e5;
+}
+
+.release-distribution-summary {
+ display: grid;
+ gap: 6px;
+ padding: 13px 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #f8faf8;
+}
+
+.release-distribution-summary strong {
+ color: var(--ink);
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.release-distribution-summary span {
+ color: #7d8985;
+ font-size: 9px;
+}
+
+.release-distribution-warning {
+ margin: 0;
+ padding: 13px 14px;
+ border: 1px solid #f1dfc8;
+ border-radius: 10px;
+ color: #805e35;
+ background: #fffaf3;
+ font-size: 10px;
+ line-height: 1.65;
+}
+
+.release-confirm-button {
+ display: inline-flex;
+ min-height: 38px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 15px;
+ border: 1px solid #a56a26;
+ border-radius: 9px;
+ color: white;
+ background: #b9792f;
+ font-size: 11px;
+ font-weight: 650;
+}
+
+.release-confirm-button:hover {
+ background: #9d6425;
+}
+
+.release-confirm-button:disabled {
+ cursor: wait;
+ opacity: 0.62;
+}
+
.user-delete-warning {
margin: 0;
padding: 13px 14px;
diff --git a/docs/KOC LOOP 部署指南.md b/docs/KOC LOOP 部署指南.md
index 57d29cf..383b6b3 100644
--- a/docs/KOC LOOP 部署指南.md
+++ b/docs/KOC LOOP 部署指南.md
@@ -237,10 +237,10 @@ npm run db:generate
运营后台 Worker 配置了 Cloudflare Cron:
```text
-0 2 * * *
+0 1 * * *
```
-Cloudflare Cron 使用 UTC,`02:00 UTC` 对应北京时间每天 `10:00`。定时任务会:
+Cloudflare Cron 使用 UTC,`01:00 UTC` 对应北京时间每天 `09:00`。定时任务会:
1. 确认数据库结构;
2. 执行当天已创建的笔记数据采集任务;
diff --git a/lib/collection-schedule.ts b/lib/collection-schedule.ts
new file mode 100644
index 0000000..59fbef3
--- /dev/null
+++ b/lib/collection-schedule.ts
@@ -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)
+ );
+}
diff --git a/lib/collection-service.ts b/lib/collection-service.ts
index 28707fd..5b92872 100644
--- a/lib/collection-service.ts
+++ b/lib/collection-service.ts
@@ -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}天自动追采`
: "成功 · 手动采集";
diff --git a/lib/distribution-release-service.ts b/lib/distribution-release-service.ts
new file mode 100644
index 0000000..f1209f7
--- /dev/null
+++ b/lib/distribution-release-service.ts
@@ -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();
+
+ 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,
+ };
+}
diff --git a/lib/mcp-operations.ts b/lib/mcp-operations.ts
index edfe3a3..e28ca5e 100644
--- a/lib/mcp-operations.ts
+++ b/lib/mcp-operations.ts
@@ -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(
diff --git a/lib/mcp-tools.ts b/lib/mcp-tools.ts
index 587c212..297749b 100644
--- a/lib/mcp-tools.ts
+++ b/lib/mcp-tools.ts
@@ -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"),
diff --git a/lib/sort-utils.ts b/lib/sort-utils.ts
new file mode 100644
index 0000000..388b3a5
--- /dev/null
+++ b/lib/sort-utils.ts
@@ -0,0 +1,26 @@
+export type SortDirection = "asc" | "desc";
+
+export function sortWithNullsLast(
+ 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);
+}
diff --git a/tests/collection-schedule.test.mjs b/tests/collection-schedule.test.mjs
new file mode 100644
index 0000000..924415e
--- /dev/null
+++ b/tests/collection-schedule.test.mjs
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { isCollectionScheduleDue } from "../lib/collection-schedule.ts";
+import { sortWithNullsLast } from "../lib/sort-utils.ts";
+
+test("runs the Beijing collection schedule from 09:00", () => {
+ const scheduledDate = "2026-08-12";
+ assert.equal(
+ isCollectionScheduleDue(scheduledDate, Date.parse("2026-08-12T00:59:59Z")),
+ false,
+ );
+ assert.equal(
+ isCollectionScheduleDue(scheduledDate, Date.parse("2026-08-12T01:00:00Z")),
+ true,
+ );
+ assert.equal(
+ isCollectionScheduleDue(scheduledDate, Date.parse("2026-08-13T00:00:00Z")),
+ true,
+ );
+});
+
+test("sorts both directions while keeping missing values last", () => {
+ const values = [
+ { id: "missing-a", value: null },
+ { id: "middle", value: 20 },
+ { id: "high", value: 50 },
+ { id: "missing-b", value: null },
+ { id: "low", value: 10 },
+ ];
+ assert.deepEqual(
+ sortWithNullsLast(values, (item) => item.value, "asc").map((item) => item.id),
+ ["low", "middle", "high", "missing-a", "missing-b"],
+ );
+ assert.deepEqual(
+ sortWithNullsLast(values, (item) => item.value, "desc").map((item) => item.id),
+ ["high", "middle", "low", "missing-a", "missing-b"],
+ );
+});
diff --git a/tests/distribution-release.test.mjs b/tests/distribution-release.test.mjs
new file mode 100644
index 0000000..dabb062
--- /dev/null
+++ b/tests/distribution-release.test.mjs
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import {
+ DistributionReleaseError,
+ distributionReleaseBlockReason,
+} from "../lib/distribution-release-service.ts";
+
+test("allows only unfinished assignments to return to the claim pool", () => {
+ assert.equal(
+ distributionReleaseBlockReason({ publishUrl: null, resultSubmittedAt: null }),
+ null,
+ );
+ assert.equal(
+ distributionReleaseBlockReason({
+ publishUrl: "https://www.xiaohongshu.com/discovery/item/example",
+ resultSubmittedAt: null,
+ }),
+ "已回填发布链接的笔记不能释放",
+ );
+ assert.equal(
+ distributionReleaseBlockReason({
+ publishUrl: null,
+ resultSubmittedAt: "2026-08-12 09:00:00",
+ }),
+ "已提交结果截图的任务不能释放",
+ );
+ assert.equal(new DistributionReleaseError("blocked", 409).status, 409);
+});
+
+test("releases a claim in one D1 batch and restores counters", async () => {
+ const [service, actionRoute, adminApp] = await Promise.all([
+ readFile(new URL("../lib/distribution-release-service.ts", import.meta.url), "utf8"),
+ readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
+ readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
+ ]);
+ assert.match(service, /await db\.batch\(statements\)/);
+ assert.match(service, /DELETE FROM distributions WHERE id = \?/);
+ assert.match(service, /UPDATE contents SET status = 'available'/);
+ assert.match(service, /claimed_quantity = MAX\(claimed_quantity - 1, 0\)/);
+ assert.match(service, /claimed_total = MAX\(claimed_total - 1, 0\)/);
+ assert.match(service, /UPDATE claims/);
+ assert.match(service, /UPDATE delegation_bundles/);
+ assert.match(actionRoute, /body\.action === "release_distribution"/);
+ assert.match(actionRoute, /isManagerRequest/);
+ assert.match(adminApp, /确认释放这篇笔记/);
+ assert.match(adminApp, /已释放,可重新领取/);
+ assert.match(adminApp, /role="alertdialog"/);
+});
diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs
index 7cb96ca..1ea6685 100644
--- a/tests/rendered-html.test.mjs
+++ b/tests/rendered-html.test.mjs
@@ -214,6 +214,10 @@ test("supports task collection schedules and latest public metrics", async () =>
}
assert.match(adminApp, /选择开始日期与采集日/);
assert.match(adminApp, /第1天到第7天/);
+ assert.match(adminApp, /SortableRecoveryHeader/);
+ assert.match(adminApp, /sortRecoveryDistributions/);
+ assert.match(adminApp, /aria-sort=/);
+ assert.match(adminApp, /direction === "asc" \? "↑" : "↓"/);
assert.match(adminApp, /内容 \/ 发布账号/);
assert.match(adminApp, /recovery-title-link/);
assert.match(adminApp, /打开小红书笔记/);
@@ -233,17 +237,18 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.match(collectionService, /runDueScheduledCollections/);
assert.match(collectionService, /retryFailedCollections/);
assert.match(collectionService, /exposure IS NOT NULL AND views IS NOT NULL/);
- assert.match(collectionService, /等待第\$\{scheduleDay\}天 10:00自动采集/);
+ assert.match(collectionService, /等待第\$\{scheduleDay\}天 09:00自动采集/);
+ assert.match(collectionService, /isCollectionScheduleDue/);
assert.doesNotMatch(collectionService, /latestDueSchedule/);
assert.match(collectionService, /自动追采/);
assert.match(worker, /async scheduled/);
assert.match(worker, /backfillAccountProfiles/);
assert.match(bootstrapRoute, /backfillAccountProfiles/);
- assert.match(viteConfig, /"0 2 \* \* \*"/);
+ assert.match(viteConfig, /"0 1 \* \* \*"/);
assert.match(migration, /latest_likes/);
assert.match(migration, /collection_runs_distribution_date_idx/);
assert.match(accountMigration, /public_account_id/);
- assert.match(deployConfig, /"crons":\["0 2 \* \* \*"\]/);
+ assert.match(deployConfig, /"crons":\["0 1 \* \* \*"\]/);
});
test("exports complete task recovery data to Excel with embedded images", async () => {
diff --git a/vite.config.ts b/vite.config.ts
index 0d91708..d93900b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,8 +14,8 @@ const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
- // Cloudflare cron uses UTC. 02:00 UTC is 10:00 in Asia/Shanghai.
- triggers: { crons: ["0 2 * * *"] },
+ // Cloudflare cron uses UTC. 01:00 UTC is 09:00 in Asia/Shanghai.
+ triggers: { crons: ["0 1 * * *"] },
d1_databases: d1
? [
{