Compare commits
1 Commits
7ef150e08b
...
codex/site
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1766a90cc1 |
@@ -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 (
|
||||
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
|
||||
<button
|
||||
type="button"
|
||||
className={`recovery-sort-button${active ? " active" : ""}`}
|
||||
onClick={() => onSort(column)}
|
||||
title={`${label}按${active && direction === "desc" ? "正序" : "倒序"}排列`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<b aria-hidden="true">{arrow}</b>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
<DistributionPage
|
||||
tasks={data.tasks}
|
||||
distributions={data.distributions}
|
||||
canRelease={isManager}
|
||||
working={working}
|
||||
onRelease={async (distribution) =>
|
||||
runAction(
|
||||
{
|
||||
action: "release_distribution",
|
||||
distributionId: distribution.id,
|
||||
},
|
||||
`“${distribution.content_title}”已释放,可重新领取`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "resources" && (
|
||||
@@ -1058,7 +1143,7 @@ function OverviewPage({
|
||||
<div>
|
||||
<p className="eyebrow">今天最需要推进</p>
|
||||
<h3>把已发布笔记的数据收回来</h3>
|
||||
<p>按任务选择采集日期,系统在当天10:00更新点赞、收藏、评论和总互动。</p>
|
||||
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。</p>
|
||||
</div>
|
||||
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
||||
</div>
|
||||
@@ -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<boolean>;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (!selectedTask) {
|
||||
return (
|
||||
@@ -1325,8 +1417,69 @@ function DistributionPage({
|
||||
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div>
|
||||
<div className="filter-chips"><button className="active">全部</button><button>待发布</button><button>采集中</button></div>
|
||||
</div>
|
||||
<DistributionTable distributions={taskDistributions} />
|
||||
<DistributionTable
|
||||
distributions={taskDistributions}
|
||||
onRelease={canRelease ? setReleaseTarget : undefined}
|
||||
/>
|
||||
</section>
|
||||
{releaseTarget && (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
|
||||
>
|
||||
<div
|
||||
className="modal-card compact release-distribution-modal"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="release-distribution-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-heading">
|
||||
<div>
|
||||
<p className="eyebrow warning">释放领取</p>
|
||||
<h2 id="release-distribution-title">确认释放这篇笔记?</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReleaseTarget(null)}
|
||||
aria-label="关闭"
|
||||
disabled={working}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="release-distribution-summary">
|
||||
<strong>{releaseTarget.content_title}</strong>
|
||||
<span>当前领取合作方:{releaseTarget.partner_name}</span>
|
||||
</div>
|
||||
<p className="release-distribution-warning">
|
||||
释放后,这篇笔记会从当前领取记录中移除并回到可领取池,其他 KOC 可以重新领取。此操作不能撤销。
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => setReleaseTarget(null)}
|
||||
disabled={working}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="release-confirm-button"
|
||||
onClick={async () => {
|
||||
const released = await onRelease(releaseTarget);
|
||||
if (released) setReleaseTarget(null);
|
||||
}}
|
||||
disabled={working}
|
||||
>
|
||||
{working ? "释放中…" : "确认释放"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1334,9 +1487,11 @@ function DistributionPage({
|
||||
function DistributionTable({
|
||||
distributions,
|
||||
compact = false,
|
||||
onRelease,
|
||||
}: {
|
||||
distributions: Distribution[];
|
||||
compact?: boolean;
|
||||
onRelease?: (distribution: Distribution) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
@@ -1349,6 +1504,7 @@ function DistributionTable({
|
||||
<th>发布时间</th>
|
||||
<th>数据状态</th>
|
||||
<th>状态</th>
|
||||
{onRelease && <th>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1382,6 +1538,21 @@ function DistributionTable({
|
||||
)}
|
||||
</td>
|
||||
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
|
||||
{onRelease && (
|
||||
<td>
|
||||
{!item.publish_url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="release-distribution-button"
|
||||
onClick={() => onRelease(item)}
|
||||
>
|
||||
释放
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1661,6 +1832,10 @@ function RecoveryPage({
|
||||
exportingTaskId: string | null;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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 (
|
||||
<div className="stack">
|
||||
<TaskDetailHeader
|
||||
@@ -1731,19 +1923,55 @@ function RecoveryPage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>内容 / 发布账号</th>
|
||||
<th>发布时间</th>
|
||||
<th>点赞</th>
|
||||
<th>收藏</th>
|
||||
<th>评论</th>
|
||||
<th>总互动</th>
|
||||
<th>数据更新时间</th>
|
||||
<SortableRecoveryHeader
|
||||
label="发布时间"
|
||||
column="publish_time"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="点赞"
|
||||
column="likes"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="收藏"
|
||||
column="collects"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="评论"
|
||||
column="comments"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="总互动"
|
||||
column="total"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="数据更新时间"
|
||||
column="collection_updated_at"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<th>采集状态</th>
|
||||
<th>创作者截图</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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({
|
||||
<div>
|
||||
<p className="eyebrow">自动采集计划</p>
|
||||
<h2>选择开始日期与采集日</h2>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间10:00自动采集。</span>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
|
||||
</div>
|
||||
<div className="schedule-save-area">
|
||||
<span>已选择 {days.length} 天</span>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
110
app/globals.css
110
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;
|
||||
|
||||
@@ -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. 执行当天已创建的笔记数据采集任务;
|
||||
|
||||
25
lib/collection-schedule.ts
Normal file
25
lib/collection-schedule.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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}天自动追采`
|
||||
: "成功 · 手动采集";
|
||||
|
||||
137
lib/distribution-release-service.ts
Normal file
137
lib/distribution-release-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
26
lib/sort-utils.ts
Normal 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);
|
||||
}
|
||||
38
tests/collection-schedule.test.mjs
Normal file
38
tests/collection-schedule.test.mjs
Normal file
@@ -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"],
|
||||
);
|
||||
});
|
||||
49
tests/distribution-release.test.mjs
Normal file
49
tests/distribution-release.test.mjs
Normal file
@@ -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"/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user