diff --git a/.env.self-hosted.example b/.env.self-hosted.example index a06b4ea..b731e99 100644 --- a/.env.self-hosted.example +++ b/.env.self-hosted.example @@ -24,7 +24,7 @@ FEISHU_APP_SECRET= AI_TOOL_CENTER_MCP_URL= AI_TOOL_CENTER_MCP_KEY= -# 每天北京时间 10:00 自动执行采集计划。 +# 每天北京时间 09:00 自动执行采集计划。 ENABLE_SCHEDULER=true SEED_DEMO_DATA=false HTTP_PORT=80 diff --git a/app/admin-app.tsx b/app/admin-app.tsx index fdacfe8..b5f284a 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -14,6 +14,10 @@ import { } from "../lib/date-utils"; import type { AuthUser } from "../lib/user-auth"; import { parseResultScreenshotKeys } from "../lib/result-screenshots"; +import { + sortWithNullsLast, + type SortDirection, +} from "../lib/sort-utils"; import UsersPage from "./users-page"; type Partner = { @@ -120,6 +124,14 @@ type Distribution = { due_at: string; }; +type RecoverySortKey = + | "publish_time" + | "likes" + | "collects" + | "comments" + | "total" + | "collection_updated_at"; + type DashboardData = { partners: Partner[]; tasks: Task[]; @@ -229,6 +241,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; } @@ -617,7 +691,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { startDate, days, }, - `采集任务已创建,将在所选日期10:00自动执行`, + `采集任务已创建,将在所选日期09:00自动执行`, ); }; @@ -893,6 +967,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { + runAction( + { + action: "release_distribution", + distributionId: distribution.id, + }, + `“${distribution.content_title}”已释放,可重新领取`, + ) + } /> )} {activeNav === "resources" && ( @@ -1247,7 +1332,7 @@ function OverviewPage({

今天最需要推进

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

-

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

+

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

@@ -1489,11 +1574,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 ( @@ -1540,9 +1632,70 @@ function DistributionPage({ {isScreenshotTask ? ( ) : ( - + )} + {releaseTarget && ( +
{ if (!working) setReleaseTarget(null); }} + > +
event.stopPropagation()} + > +
+
+

释放领取

+

确认释放这篇笔记?

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

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

+
+ + +
+
+
+ )} ); } @@ -1550,9 +1703,11 @@ function DistributionPage({ function DistributionTable({ distributions, compact = false, + onRelease, }: { distributions: Distribution[]; compact?: boolean; + onRelease?: (distribution: Distribution) => void; }) { return (
@@ -1565,6 +1720,7 @@ function DistributionTable({ 发布时间 数据状态 状态 + {onRelease && 操作} @@ -1598,6 +1754,21 @@ function DistributionTable({ )} {statusLabel(item.status)} + {onRelease && ( + + {!item.publish_url && !item.result_submitted_at ? ( + + ) : ( + + )} + + )} ))} @@ -2233,6 +2404,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 ( @@ -2295,6 +2470,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; @@ -2512,7 +2740,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 416c78a..31170ce 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -30,6 +30,11 @@ import { createDistributionTask, createScreenshotTask, } from "../../../lib/task-service"; +import { + DistributionReleaseError, + releaseUnfinishedDistribution, +} from "../../../lib/distribution-release-service"; +import { isManagerRequest } from "../../../lib/user-auth"; type ActionBody = { action?: string; @@ -135,6 +140,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(); @@ -198,7 +209,7 @@ export async function POST(request: Request) { AND publish_url != ''`, ) .bind( - `已安排${days.length}个采集日,每日10:00执行`, + `已安排${days.length}个采集日,每日09:00执行`, taskId, ), ]); @@ -362,7 +373,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 df9154b..1a9d597 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2264,6 +2264,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; } @@ -3572,6 +3607,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 cf1146d..33360a6 100644 --- a/docs/KOC LOOP 私有化部署指南.md +++ b/docs/KOC LOOP 私有化部署指南.md @@ -7,7 +7,7 @@ | 组件 | 容器 | 作用 | 持久化 | | --- | --- | --- | --- | | Nginx | `nginx` | 公网入口、反向代理、托管 KOC 领取页 | 配置随镜像 | -| KOC 服务 | `app` | Next.js 后台、API、MCP、每天 10:00 自动采集 | 上传目录挂载卷 | +| KOC 服务 | `app` | Next.js 后台、API、MCP、每天 09:00 自动采集 | 上传目录挂载卷 | | MySQL 8 | `mysql` | 任务、笔记、领取、回填、账号、采集和用户数据 | MySQL 数据卷 | 访问路径: @@ -53,7 +53,7 @@ cp .env.self-hosted.example .env.self-hosted | `KOC_MCP_API_KEY` | Agent 调用 KOC LOOP MCP 的独立 Bearer 密钥 | | `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 | | `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 | -| `ENABLE_SCHEDULER` | 是否启用每天 10:00 自动采集,生产保持 `true` | +| `ENABLE_SCHEDULER` | 是否启用每天 09:00 自动采集,生产保持 `true` | 密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。 @@ -189,7 +189,7 @@ docker compose --env-file .env.self-hosted \ | `/api/health` 返回 503 | 查看 MySQL 容器健康状态与应用数据库变量 | | 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 | | 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` | -| 10:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 | +| 09:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 | | MCP 401 | 检查请求头是否为 `Authorization: Bearer ` | | 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 | 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-service.ts b/lib/collection-service.ts index 71a8d73..f0022e1 100644 --- a/lib/collection-service.ts +++ b/lib/collection-service.ts @@ -60,13 +60,23 @@ function shanghaiHourFromTimestamp(timestamp: number) { ); } +export function isCollectionScheduleDue( + scheduledDate: string, + timestamp: number, +) { + const currentDate = shanghaiDateFromTimestamp(timestamp); + const currentHour = shanghaiHourFromTimestamp(timestamp); + return ( + scheduledDate < currentDate || + (scheduledDate === currentDate && currentHour >= 9) + ); +} + function dueSchedules( startDate: string, days: number[], timestamp: number, ) { - const currentDate = shanghaiDateFromTimestamp(timestamp); - const currentHour = shanghaiHourFromTimestamp(timestamp); return days .map((scheduleDay) => ({ scheduleDay, @@ -78,8 +88,7 @@ function dueSchedules( ): value is { scheduleDay: number; scheduledDate: string } => Boolean( value.scheduledDate && - (value.scheduledDate < currentDate || - (value.scheduledDate === currentDate && currentHour >= 10)), + isCollectionScheduleDue(value.scheduledDate, timestamp), ), ); } @@ -131,8 +140,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自动采集`, ), ); } @@ -162,7 +171,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( @@ -231,7 +240,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/database.ts b/lib/database.ts index fd849d3..71fc47b 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -155,6 +155,23 @@ export class DatabaseClient { connection.release(); } } + + async transaction(operation: (database: DatabaseClient) => Promise) { + const pool = getPool(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const transaction = new DatabaseClient(connection); + const result = await operation(transaction); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } } declare global { diff --git a/lib/distribution-release-service.ts b/lib/distribution-release-service.ts new file mode 100644 index 0000000..f4e3c42 --- /dev/null +++ b/lib/distribution-release-service.ts @@ -0,0 +1,153 @@ +import type { DatabaseClient } from "./database"; + +type ReleasableDistribution = { + id: string; + task_id: string; + task_type: 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; + result_submitted_at: 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; + taskType?: string | null; +}) { + if (input.publishUrl) return "已回填发布链接的笔记不能释放"; + if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放"; + return null; +} + +export async function releaseUnfinishedDistribution( + database: DatabaseClient, + distributionId: string, +) { + if (!distributionId) { + throw new DistributionReleaseError("请选择需要释放的领取记录", 400); + } + + return database.transaction(async (db) => { + const distribution = await db + .prepare( + `SELECT + d.id, + d.task_id, + t.task_type, + 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, + d.result_submitted_at + FROM distributions d + JOIN tasks t ON t.id = d.task_id + JOIN contents c ON c.id = d.content_id + JOIN partners p ON p.id = d.partner_id + WHERE d.id = ? + FOR UPDATE`, + ) + .bind(distributionId) + .first(); + + if (!distribution) { + throw new DistributionReleaseError("领取记录不存在或已被释放", 404); + } + const blocked = distributionReleaseBlockReason({ + publishUrl: distribution.publish_url, + resultSubmittedAt: distribution.result_submitted_at, + taskType: distribution.task_type, + }); + if (blocked) throw new DistributionReleaseError(blocked, 409); + + await db + .prepare("DELETE FROM collection_runs WHERE distribution_id = ?") + .bind(distribution.id) + .run(); + await db + .prepare("DELETE FROM distributions WHERE id = ?") + .bind(distribution.id) + .run(); + await db + .prepare("UPDATE contents SET status = 'available' WHERE id = ?") + .bind(distribution.content_id) + .run(); + await db + .prepare( + `UPDATE tasks + SET claimed_quantity = GREATEST(claimed_quantity - 1, 0) + WHERE id = ?`, + ) + .bind(distribution.task_id) + .run(); + await db + .prepare( + `UPDATE partners + SET claimed_total = GREATEST(claimed_total - 1, 0) + WHERE id = ?`, + ) + .bind(distribution.partner_id) + .run(); + + if (distribution.delegation_bundle_id) { + await db + .prepare( + `UPDATE delegation_bundles + SET quantity = GREATEST(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) + .run(); + } + + if (distribution.claim_id) { + await db + .prepare( + `UPDATE claims + SET quantity = GREATEST(quantity - 1, 0) + WHERE id = ?`, + ) + .bind(distribution.claim_id) + .run(); + await 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) + .run(); + } + + 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 885013c..609b9c8 100644 --- a/lib/mcp-operations.ts +++ b/lib/mcp-operations.ts @@ -284,7 +284,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/scheduler.ts b/lib/scheduler.ts index 1ee8eae..6db9812 100644 --- a/lib/scheduler.ts +++ b/lib/scheduler.ts @@ -33,11 +33,11 @@ export function startScheduler() { if (!isEnabled(getRuntimeEnv().ENABLE_SCHEDULER, true)) return; if (globalThis.__kocLoopScheduler) return; globalThis.__kocLoopScheduler = cron.schedule( - "0 10 * * *", + "0 9 * * *", () => void runDailyJob().catch((error) => { console.error("[KOC LOOP] daily scheduler failed", error); }), { timezone: "Asia/Shanghai", noOverlap: true }, ); - console.info("[KOC LOOP] scheduler enabled at 10:00 Asia/Shanghai"); + console.info("[KOC LOOP] scheduler enabled at 09:00 Asia/Shanghai"); } 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..d24ef01 --- /dev/null +++ b/tests/collection-schedule.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isCollectionScheduleDue } from "../lib/collection-service.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, + ); + assert.equal( + isCollectionScheduleDue( + scheduledDate, + Date.parse("2026-08-11T16:00:00Z"), + ), + false, + ); +}); + +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..44559af --- /dev/null +++ b/tests/distribution-release.test.mjs @@ -0,0 +1,58 @@ +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, + taskType: "content_publish", + }), + null, + ); + assert.equal( + distributionReleaseBlockReason({ + publishUrl: "https://www.xiaohongshu.com/discovery/item/example", + resultSubmittedAt: null, + taskType: "content_publish", + }), + "已回填发布链接的笔记不能释放", + ); + assert.equal( + distributionReleaseBlockReason({ + publishUrl: null, + resultSubmittedAt: "2026-08-12 09:00:00", + taskType: "screenshot_collect", + }), + "已提交结果截图的任务不能释放", + ); + assert.equal(new DistributionReleaseError("blocked", 409).status, 409); +}); + +test("releases a claim atomically and restores all counters", async () => { + const [service, database, actionRoute, adminApp] = await Promise.all([ + readFile(new URL("../lib/distribution-release-service.ts", import.meta.url), "utf8"), + readFile(new URL("../lib/database.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(database, /async transaction/); + assert.match(service, /FOR UPDATE/); + assert.match(service, /DELETE FROM distributions WHERE id = \?/); + assert.match(service, /UPDATE contents SET status = 'available'/); + assert.match(service, /claimed_quantity = GREATEST\(claimed_quantity - 1, 0\)/); + assert.match(service, /claimed_total = GREATEST\(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 a60351b..434accd 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -75,6 +75,8 @@ test("organizes distribution and recovery by task and imports the verified Feish assert.match(adminApp, /选择一个数据回收任务/); assert.match(adminApp, /读取表格/); assert.match(adminApp, /当前仅展示/); + assert.match(adminApp, /release_distribution/); + assert.match(adminApp, /确认释放这篇笔记/); assert.equal(snapshot.sheetId, "954953"); assert.equal(snapshot.rows.length, 61); assert.equal( @@ -229,6 +231,11 @@ 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, /sortWithNullsLast/); assert.match(adminApp, /内容 \/ 发布账号/); assert.match(adminApp, /recovery-title-link/); assert.match(adminApp, /打开小红书笔记/); @@ -252,10 +259,11 @@ 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(scheduler, /0 10 \* \* \*/); + assert.match(scheduler, /0 9 \* \* \*/); assert.match(scheduler, /backfillAccountProfiles/); assert.match(scheduler, /Asia\/Shanghai/); assert.match(bootstrapRoute, /backfillAccountProfiles/);