From 1766a90cc16ac11b735e40f65553ef9d1e580d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B7=AB=E5=87=A4=E8=90=8D?= Date: Wed, 12 Aug 2026 11:43:26 +0800 Subject: [PATCH] feat: add release controls and 09:00 collection --- app/admin-app.tsx | 250 ++++++++++++++++++++++++++-- app/api/action/route.ts | 21 ++- app/globals.css | 110 ++++++++++++ docs/KOC LOOP 部署指南.md | 4 +- lib/collection-schedule.ts | 25 +++ lib/collection-service.ts | 24 +-- lib/distribution-release-service.ts | 137 +++++++++++++++ lib/mcp-operations.ts | 2 +- lib/mcp-tools.ts | 2 +- lib/sort-utils.ts | 26 +++ tests/collection-schedule.test.mjs | 38 +++++ tests/distribution-release.test.mjs | 49 ++++++ tests/rendered-html.test.mjs | 11 +- vite.config.ts | 4 +- 14 files changed, 665 insertions(+), 38 deletions(-) create mode 100644 lib/collection-schedule.ts create mode 100644 lib/distribution-release-service.ts create mode 100644 lib/sort-utils.ts create mode 100644 tests/collection-schedule.test.mjs create mode 100644 tests/distribution-release.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 0b1fb5a..a8d9055 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -13,6 +13,10 @@ import { formatShanghaiToday, } from "../lib/date-utils"; import type { AuthUser } from "../lib/user-auth"; +import { + sortWithNullsLast, + type SortDirection, +} from "../lib/sort-utils"; import UsersPage from "./users-page"; type Partner = { @@ -106,6 +110,14 @@ type DashboardData = { portal_url: string; }; +type RecoverySortKey = + | "publish_time" + | "likes" + | "collects" + | "comments" + | "total" + | "collection_updated_at"; + type FeishuPreview = { sheetId: string; sheetName: string; @@ -207,6 +219,68 @@ function latestPublicMetrics(distribution: Distribution) { }; } +function recoverySortValue( + distribution: Distribution, + key: RecoverySortKey, +): number | null { + const metrics = latestPublicMetrics(distribution); + if (key === "likes") return metrics.likes; + if (key === "collects") return metrics.collects; + if (key === "comments") return metrics.comments; + if (key === "total") return metrics.total; + + const value = + key === "publish_time" + ? distribution.publish_time + : distribution.collection_updated_at || + (metrics.likes !== null ? distribution.updated_at : null); + if (!value) return null; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function sortRecoveryDistributions( + distributions: Distribution[], + key: RecoverySortKey, + direction: SortDirection, +) { + return sortWithNullsLast( + distributions, + (distribution) => recoverySortValue(distribution, key), + direction, + ); +} + +function SortableRecoveryHeader({ + label, + column, + activeColumn, + direction, + onSort, +}: { + label: string; + column: RecoverySortKey; + activeColumn: RecoverySortKey | null; + direction: SortDirection; + onSort: (column: RecoverySortKey) => void; +}) { + const active = activeColumn === column; + const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕"; + return ( + + + + ); +} + function hasCreatorMetrics(distribution: Distribution) { return distribution.exposure !== null && distribution.views !== null; } @@ -515,7 +589,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { startDate, days, }, - `采集任务已创建,将在所选日期10:00自动执行`, + `采集任务已创建,将在所选日期09:00自动执行`, ); }; @@ -786,6 +860,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { + runAction( + { + action: "release_distribution", + distributionId: distribution.id, + }, + `“${distribution.content_title}”已释放,可重新领取`, + ) + } /> )} {activeNav === "resources" && ( @@ -1058,7 +1143,7 @@ function OverviewPage({

今天最需要推进

把已发布笔记的数据收回来

-

按任务选择采集日期,系统在当天10:00更新点赞、收藏、评论和总互动。

+

按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。

@@ -1278,11 +1363,18 @@ function TaskDetailHeader({ function DistributionPage({ tasks, distributions, + canRelease, + working, + onRelease, }: { tasks: Task[]; distributions: Distribution[]; + canRelease: boolean; + working: boolean; + onRelease: (distribution: Distribution) => Promise; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); + const [releaseTarget, setReleaseTarget] = useState(null); const selectedTask = tasks.find((task) => task.id === selectedTaskId); if (!selectedTask) { return ( @@ -1325,8 +1417,69 @@ function DistributionPage({

内容分发表

实际发布账号在链接回填后自动补齐

- + + {releaseTarget && ( +
{ if (!working) setReleaseTarget(null); }} + > +
event.stopPropagation()} + > +
+
+

释放领取

+

确认释放这篇笔记?

+
+ +
+
+ {releaseTarget.content_title} + 当前领取合作方:{releaseTarget.partner_name} +
+

+ 释放后,这篇笔记会从当前领取记录中移除并回到可领取池,其他 KOC 可以重新领取。此操作不能撤销。 +

+
+ + +
+
+
+ )} ); } @@ -1334,9 +1487,11 @@ function DistributionPage({ function DistributionTable({ distributions, compact = false, + onRelease, }: { distributions: Distribution[]; compact?: boolean; + onRelease?: (distribution: Distribution) => void; }) { return (
@@ -1349,6 +1504,7 @@ function DistributionTable({ 发布时间 数据状态 状态 + {onRelease && 操作} @@ -1382,6 +1538,21 @@ function DistributionTable({ )} {statusLabel(item.status)} + {onRelease && ( + + {!item.publish_url ? ( + + ) : ( + + )} + + )} ))} @@ -1661,6 +1832,10 @@ function RecoveryPage({ exportingTaskId: string | null; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); + const [recoverySort, setRecoverySort] = useState<{ + column: RecoverySortKey | null; + direction: SortDirection; + }>({ column: null, direction: "desc" }); const selectedTask = tasks.find((task) => task.id === selectedTaskId); if (!selectedTask) { return ( @@ -1685,6 +1860,23 @@ function RecoveryPage({ const failedCollectionCount = taskDistributions.filter( (item) => item.collection_status === "failed", ).length; + const sortedTaskDistributions = recoverySort.column + ? sortRecoveryDistributions( + taskDistributions, + recoverySort.column, + recoverySort.direction, + ) + : taskDistributions; + const changeRecoverySort = (column: RecoverySortKey) => { + setRecoverySort((current) => + current.column === column + ? { + column, + direction: current.direction === "desc" ? "asc" : "desc", + } + : { column, direction: "desc" }, + ); + }; return (
内容 / 发布账号 - 发布时间 - 点赞 - 收藏 - 评论 - 总互动 - 数据更新时间 + + + + + + 采集状态 创作者截图 操作 - {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 ? [ {