5 Commits

Author SHA1 Message Date
巫凤萍
ad3dbdcc86 feat: collapse mobile note after first backfill 2026-08-12 20:44:57 +08:00
巫凤萍
ee6caaf9e5 fix: type resource import enrichment fallback 2026-08-12 19:59:38 +08:00
巫凤萍
e05041e037 fix: preserve KOC portal path in delegation links 2026-08-12 19:53:19 +08:00
巫凤萍
51934b0638 feat: simplify KOC resource imports 2026-08-12 17:51:57 +08:00
巫凤萍
7ef150e08b feat: add distribution release and recovery controls 2026-08-12 11:12:23 +08:00
23 changed files with 1007 additions and 73 deletions

View File

@@ -24,7 +24,7 @@ FEISHU_APP_SECRET=
AI_TOOL_CENTER_MCP_URL= AI_TOOL_CENTER_MCP_URL=
AI_TOOL_CENTER_MCP_KEY= AI_TOOL_CENTER_MCP_KEY=
# 每天北京时间 10:00 自动执行采集计划。 # 每天北京时间 09:00 自动执行采集计划。
ENABLE_SCHEDULER=true ENABLE_SCHEDULER=true
SEED_DEMO_DATA=false SEED_DEMO_DATA=false
HTTP_PORT=80 HTTP_PORT=80

View File

@@ -14,6 +14,10 @@ import {
} from "../lib/date-utils"; } from "../lib/date-utils";
import type { AuthUser } from "../lib/user-auth"; import type { AuthUser } from "../lib/user-auth";
import { parseResultScreenshotKeys } from "../lib/result-screenshots"; import { parseResultScreenshotKeys } from "../lib/result-screenshots";
import {
sortWithNullsLast,
type SortDirection,
} from "../lib/sort-utils";
import UsersPage from "./users-page"; import UsersPage from "./users-page";
type Partner = { type Partner = {
@@ -120,6 +124,14 @@ type Distribution = {
due_at: string; due_at: string;
}; };
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "comments"
| "total"
| "collection_updated_at";
type DashboardData = { type DashboardData = {
partners: Partner[]; partners: Partner[];
tasks: Task[]; 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 (
<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) { function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null; return distribution.exposure !== null && distribution.views !== null;
} }
@@ -617,7 +691,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
startDate, startDate,
days, days,
}, },
`采集任务已创建,将在所选日期10:00自动执行`, `采集任务已创建将在所选日期09:00自动执行`,
); );
}; };
@@ -893,6 +967,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
<DistributionPage <DistributionPage
tasks={data.tasks} tasks={data.tasks}
distributions={data.distributions} distributions={data.distributions}
canRelease={isManager}
working={working}
onRelease={async (distribution) =>
runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`${distribution.content_title}”已释放,可重新领取`,
)
}
/> />
)} )}
{activeNav === "resources" && ( {activeNav === "resources" && (
@@ -1247,7 +1332,7 @@ function OverviewPage({
<div> <div>
<p className="eyebrow"></p> <p className="eyebrow"></p>
<h3></h3> <h3></h3>
<p>系统在当天10:00更新点赞</p> <p>系统在当天09:00更新点赞</p>
</div> </div>
<button className="primary-button soft" onClick={() => onNavigate("recovery")}></button> <button className="primary-button soft" onClick={() => onNavigate("recovery")}></button>
</div> </div>
@@ -1489,11 +1574,18 @@ function TaskDetailHeader({
function DistributionPage({ function DistributionPage({
tasks, tasks,
distributions, distributions,
canRelease,
working,
onRelease,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId); const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) { if (!selectedTask) {
return ( return (
@@ -1540,9 +1632,70 @@ function DistributionPage({
{isScreenshotTask ? ( {isScreenshotTask ? (
<ScreenshotTaskTable distributions={taskDistributions} /> <ScreenshotTaskTable distributions={taskDistributions} />
) : ( ) : (
<DistributionTable distributions={taskDistributions} /> <DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
/>
)} )}
</section> </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> </div>
); );
} }
@@ -1550,9 +1703,11 @@ function DistributionPage({
function DistributionTable({ function DistributionTable({
distributions, distributions,
compact = false, compact = false,
onRelease,
}: { }: {
distributions: Distribution[]; distributions: Distribution[];
compact?: boolean; compact?: boolean;
onRelease?: (distribution: Distribution) => void;
}) { }) {
return ( return (
<div className="table-scroll"> <div className="table-scroll">
@@ -1565,6 +1720,7 @@ function DistributionTable({
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
{onRelease && <th></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1598,6 +1754,21 @@ function DistributionTable({
)} )}
</td> </td>
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td> <td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
{onRelease && (
<td>
{!item.publish_url && !item.result_submitted_at ? (
<button
type="button"
className="release-distribution-button"
onClick={() => onRelease(item)}
>
</button>
) : (
<span className="muted"></span>
)}
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -2090,9 +2261,9 @@ function ResourcesPage({
<button type="button" onClick={closeImport} aria-label="关闭">×</button> <button type="button" onClick={closeImport} aria-label="关闭">×</button>
</div> </div>
<div className="import-guide-strip"> <div className="import-guide-strip">
<span>1</span><p></p> <span>1</span><p></p>
<i /> <i />
<span>2</span><p></p> <span>2</span><p></p>
<i /> <i />
<span>3</span><p></p> <span>3</span><p></p>
</div> </div>
@@ -2111,7 +2282,7 @@ function ResourcesPage({
</label> </label>
{!importPreview && ( {!importPreview && (
<div className="import-template-note"> <div className="import-template-note">
<div><strong></strong><span>IP属地和粉丝数会自动解析</span></div> <div><strong></strong><span>IDIP属地</span></div>
<a className="ghost-button" download href="/KOC资源导入模板.xlsx"></a> <a className="ghost-button" download href="/KOC资源导入模板.xlsx"></a>
</div> </div>
)} )}
@@ -2233,6 +2404,10 @@ function RecoveryPage({
exportingTaskId: string | null; exportingTaskId: string | null;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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); const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) { if (!selectedTask) {
return ( return (
@@ -2295,6 +2470,23 @@ function RecoveryPage({
const failedCollectionCount = taskDistributions.filter( const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed", (item) => item.collection_status === "failed",
).length; ).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 ( return (
<div className="stack"> <div className="stack">
<TaskDetailHeader <TaskDetailHeader
@@ -2341,19 +2533,55 @@ function RecoveryPage({
<thead> <thead>
<tr> <tr>
<th> / </th> <th> / </th>
<th></th> <SortableRecoveryHeader
<th></th> label="发布时间"
<th></th> column="publish_time"
<th></th> activeColumn={recoverySort.column}
<th></th> direction={recoverySort.direction}
<th></th> 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> <th></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{taskDistributions.map((item) => { {sortedTaskDistributions.map((item) => {
const metrics = latestPublicMetrics(item); const metrics = latestPublicMetrics(item);
const noteUrl = xhsPublishUrl(item.publish_url); const noteUrl = xhsPublishUrl(item.publish_url);
const hasMetrics = metrics.likes !== null; const hasMetrics = metrics.likes !== null;
@@ -2512,7 +2740,7 @@ function CollectionScheduleCard({
<div> <div>
<p className="eyebrow"></p> <p className="eyebrow"></p>
<h2></h2> <h2></h2>
<span>17所选日期均在北京时间10:00自动采集</span> <span>17所选日期均在北京时间09:00自动采集</span>
</div> </div>
<div className="schedule-save-area"> <div className="schedule-save-area">
<span> {days.length} </span> <span> {days.length} </span>

View File

@@ -30,6 +30,11 @@ import {
createDistributionTask, createDistributionTask,
createScreenshotTask, createScreenshotTask,
} from "../../../lib/task-service"; } from "../../../lib/task-service";
import {
DistributionReleaseError,
releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service";
import { isManagerRequest } from "../../../lib/user-auth";
type ActionBody = { type ActionBody = {
action?: string; action?: string;
@@ -135,6 +140,12 @@ export async function POST(request: Request) {
.bind(available.results.length, partnerId), .bind(available.results.length, partnerId),
); );
await db.batch(statements); 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") { } else if (body.action === "save_collection_schedule") {
const taskId = String(body.taskId ?? "").trim(); const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim(); const startDate = String(body.startDate ?? "").trim();
@@ -198,7 +209,7 @@ export async function POST(request: Request) {
AND publish_url != ''`, AND publish_url != ''`,
) )
.bind( .bind(
`已安排${days.length}个采集日,每日10:00执行`, `已安排${days.length}个采集日每日09:00执行`,
taskId, taskId,
), ),
]); ]);
@@ -362,7 +373,13 @@ export async function POST(request: Request) {
} catch (error) { } catch (error) {
return Response.json( return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" }, { error: error instanceof Error ? error.message : "操作失败" },
{ status: error instanceof FeishuSourceError ? error.status : 500 }, {
status:
error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError
? error.status
: 500,
},
); );
} }
} }

View File

@@ -13,6 +13,7 @@ import {
parseResourceImportFile, parseResourceImportFile,
RESOURCE_IMPORT_MAX_BYTES, RESOURCE_IMPORT_MAX_BYTES,
resourcePlatformUid, resourcePlatformUid,
resourceImportMissingFields,
type ResourceImportRow, type ResourceImportRow,
} from "../../../lib/resource-import"; } from "../../../lib/resource-import";
@@ -86,20 +87,43 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
return row; return row;
} }
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl)); const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
if (existing?.nickname && existing.public_account_id) { const existingIpLocation =
return { existing?.ip_location && existing.ip_location !== "待识别"
? existing.ip_location
: "";
const baseline: ResourceImportRow = {
...row, ...row,
nickname: existing.nickname, nickname: row.nickname || existing?.nickname || "",
publicAccountId: existing.public_account_id, publicAccountId: row.publicAccountId || existing?.public_account_id || "",
ipLocation: existing.ip_location || "待识别", ipLocation: row.ipLocation || existingIpLocation,
followers: Number(existing.followers || 0), followers: row.followersResolved
? row.followers
: Number(existing?.followers || 0),
followersResolved:
row.followersResolved || Number(existing?.followers || 0) > 0,
}; };
if (resourceImportMissingFields(baseline).length === 0) {
return baseline;
} }
let details = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig) let details: {
.catch(() => resolveXhsPublicAccountDetails(row.profileUrl)); nickname: string | null;
if (!details.nickname || !details.redId || details.followers === null) { redId: string | null;
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl); followers: number | null;
ipLocation: string | null;
} = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig).catch(
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
);
const mcpResult = {
nickname: baseline.nickname || details.nickname?.trim() || "",
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
followersResolved: baseline.followersResolved || details.followers !== null,
};
if (resourceImportMissingFields(mcpResult).length > 0) {
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
);
details = { details = {
nickname: details.nickname || publicDetails.nickname, nickname: details.nickname || publicDetails.nickname,
redId: details.redId || publicDetails.redId, redId: details.redId || publicDetails.redId,
@@ -107,18 +131,16 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
ipLocation: details.ipLocation || publicDetails.ipLocation, ipLocation: details.ipLocation || publicDetails.ipLocation,
}; };
} }
const errors = [...row.errors];
const nickname = details.nickname?.trim() || existing?.nickname || "";
const publicAccountId = details.redId?.trim() || existing?.public_account_id || "";
if (!nickname) errors.push("无法识别账号名称,请确认主页可公开访问");
if (!publicAccountId) errors.push("无法识别小红书号,请确认主页可公开访问");
return { return {
...row, ...baseline,
nickname, nickname: baseline.nickname || details.nickname?.trim() || "待识别账号",
publicAccountId, publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
ipLocation: details.ipLocation?.trim() || existing?.ip_location || "待识别", ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "待识别",
followers: details.followers ?? Number(existing?.followers || 0), followers: baseline.followersResolved
errors, ? baseline.followers
: (details.followers ?? 0),
followersResolved:
baseline.followersResolved || details.followers !== null,
}; };
}); });
} }
@@ -251,7 +273,7 @@ export async function POST(request: Request) {
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END, profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
ip_location = CASE ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END, WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
followers = CASE WHEN ? > 0 OR followers = 0 THEN ? ELSE followers END, followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
cooperation_source = ?, cooperation_source = ?,
last_seen_at = CURRENT_TIMESTAMP last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`, WHERE id = ?`,
@@ -265,7 +287,7 @@ export async function POST(request: Request) {
row.ipLocation, row.ipLocation,
row.ipLocation, row.ipLocation,
row.ipLocation, row.ipLocation,
row.followers, row.followersResolved ? 1 : 0,
row.followers, row.followers,
row.cooperationSource, row.cooperationSource,
row.accountId, row.accountId,

View File

@@ -2264,6 +2264,41 @@ a {
white-space: nowrap; 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 { .recovery-content {
max-width: 360px; max-width: 360px;
} }
@@ -3572,6 +3607,81 @@ label small {
color: #b04b40; 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 { .user-delete-warning {
margin: 0; margin: 0;
padding: 13px 14px; padding: 13px 14px;

View File

@@ -7,7 +7,7 @@
| 组件 | 容器 | 作用 | 持久化 | | 组件 | 容器 | 作用 | 持久化 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Nginx | `nginx` | 公网入口、反向代理、托管 KOC 领取页 | 配置随镜像 | | Nginx | `nginx` | 公网入口、反向代理、托管 KOC 领取页 | 配置随镜像 |
| KOC 服务 | `app` | Next.js 后台、API、MCP、每天 10:00 自动采集 | 上传目录挂载卷 | | KOC 服务 | `app` | Next.js 后台、API、MCP、每天 09:00 自动采集 | 上传目录挂载卷 |
| MySQL 8 | `mysql` | 任务、笔记、领取、回填、账号、采集和用户数据 | MySQL 数据卷 | | 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 密钥 | | `KOC_MCP_API_KEY` | Agent 调用 KOC LOOP MCP 的独立 Bearer 密钥 |
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 | | `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 |
| `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 | | `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` 不得复用。 密钥必须由密码管理器生成,禁止提交到 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 容器健康状态与应用数据库变量 | | `/api/health` 返回 503 | 查看 MySQL 容器健康状态与应用数据库变量 |
| 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 | | 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 |
| 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` | | 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` |
| 10:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 | | 09:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 |
| MCP 401 | 检查请求头是否为 `Authorization: Bearer <KOC_MCP_API_KEY>` | | MCP 401 | 检查请求头是否为 `Authorization: Bearer <KOC_MCP_API_KEY>` |
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 | | 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |

View File

@@ -237,10 +237,10 @@ npm run db:generate
运营后台 Worker 配置了 Cloudflare Cron 运营后台 Worker 配置了 Cloudflare Cron
```text ```text
0 2 * * * 0 1 * * *
``` ```
Cloudflare Cron 使用 UTC`02:00 UTC` 对应北京时间每天 `10:00`。定时任务会: Cloudflare Cron 使用 UTC`01:00 UTC` 对应北京时间每天 `09:00`。定时任务会:
1. 确认数据库结构; 1. 确认数据库结构;
2. 执行当天已创建的笔记数据采集任务; 2. 执行当天已创建的笔记数据采集任务;

View File

@@ -1050,6 +1050,11 @@ footer {
padding: 34px 38px; padding: 34px 38px;
} }
.mobile-note-summary,
.mobile-note-collapse-trigger {
display: none;
}
.note-document-meta { .note-document-meta {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -1810,6 +1815,64 @@ footer {
border-radius: 16px; border-radius: 16px;
} }
.note-document.mobile-collapsed {
padding: 16px;
}
.note-document.mobile-collapsed .note-document-content {
display: none;
}
.note-document.mobile-collapsed .mobile-note-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.mobile-note-summary > div {
display: grid;
min-width: 0;
gap: 4px;
}
.mobile-note-summary span {
color: var(--green);
font-size: 9px;
font-weight: 750;
}
.mobile-note-summary strong {
overflow: hidden;
color: var(--ink);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-note-summary small {
color: #899590;
font-size: 8px;
}
.mobile-note-summary button,
.mobile-note-collapse-trigger {
flex: 0 0 auto;
min-height: 34px;
padding: 0 12px;
border: 1px solid #cfe1d9;
border-radius: 9px;
color: var(--green-deep);
background: #f2f8f5;
font-size: 9px;
font-weight: 700;
}
.mobile-note-collapse-trigger {
display: block;
margin: 14px 0 0 auto;
}
.note-document h1 { .note-document h1 {
font-size: 28px; font-size: 28px;
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { zipSync } from "fflate"; import { zipSync } from "fflate";
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
formatShanghaiDate as formatDate, formatShanghaiDate as formatDate,
parseStoredDate, parseStoredDate,
@@ -96,6 +96,13 @@ function partnerApi(path: "/api/partner" | "/api/partner-upload") {
return `${resolveAdminOrigin()}${path}`; return `${resolveAdminOrigin()}${path}`;
} }
function isMobilePortalViewport() {
return (
typeof window !== "undefined" &&
window.matchMedia("(max-width: 620px)").matches
);
}
function statusLabel(item: Assignment, taskType = "content_publish") { function statusLabel(item: Assignment, taskType = "content_publish") {
if (taskType === "screenshot_collect") { if (taskType === "screenshot_collect") {
return item.result_submitted_at ? "已提交" : "待提交"; return item.result_submitted_at ? "已提交" : "待提交";
@@ -280,6 +287,8 @@ export default function Home() {
src: string; src: string;
alt: string; alt: string;
} | null>(null); } | null>(null);
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
const submitCardRef = useRef<HTMLFormElement | null>(null);
const publishScreenshotPreview = useFilePreview(screenshot); const publishScreenshotPreview = useFilePreview(screenshot);
const creatorScreenshotPreview = useFilePreview(creatorScreenshot); const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
const taskResultPreviews = useMemo( const taskResultPreviews = useMemo(
@@ -384,6 +393,9 @@ export default function Home() {
selected.exposure === null ? "" : String(selected.exposure), selected.exposure === null ? "" : String(selected.exposure),
); );
setCreatorViews(selected.views === null ? "" : String(selected.views)); setCreatorViews(selected.views === null ? "" : String(selected.views));
setNoteContentCollapsed(
Boolean(selected.publish_url) && isMobilePortalViewport(),
);
}, 0); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [selected]); }, [selected]);
@@ -543,7 +555,9 @@ export default function Home() {
}; };
const delegationUrl = (shareToken: string) => { const delegationUrl = (shareToken: string) => {
const url = new URL(window.location.origin); const url = new URL(window.location.href);
url.search = "";
url.hash = "";
url.searchParams.set("share", shareToken); url.searchParams.set("share", shareToken);
return url.toString(); return url.toString();
}; };
@@ -792,6 +806,7 @@ export default function Home() {
const submitNote = async (event: FormEvent) => { const submitNote = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!selected) return; if (!selected) return;
const isFirstBackfill = !selected.publish_url;
try { try {
setWorking(true); setWorking(true);
if (screenshot) { if (screenshot) {
@@ -816,6 +831,15 @@ export default function Home() {
if (!response.ok) throw new Error(result.error || "回填失败"); if (!response.ok) throw new Error(result.error || "回填失败");
await loadTask(taskToken, claimToken, delegationToken); await loadTask(taskToken, claimToken, delegationToken);
setScreenshot(null); setScreenshot(null);
if (isFirstBackfill && isMobilePortalViewport()) {
setNoteContentCollapsed(true);
window.setTimeout(() => {
submitCardRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 120);
}
setToast("这篇笔记已回填,不会与其他笔记错配"); setToast("这篇笔记已回填,不会与其他笔记错配");
} catch (reason) { } catch (reason) {
setToast(reason instanceof Error ? reason.message : "回填失败"); setToast(reason instanceof Error ? reason.message : "回填失败");
@@ -1102,11 +1126,41 @@ export default function Home() {
</header> </header>
<button className="back-link" onClick={closeNote}> </button> <button className="back-link" onClick={closeNote}> </button>
<div className="detail-grid"> <div className="detail-grid">
<article className="note-document"> <article
className={`note-document ${
noteContentCollapsed ? "mobile-collapsed" : ""
}`}
>
<div className="mobile-note-summary">
<div>
<span></span>
<strong>{selected.title}</strong>
<small>
{selected.images.length} ·
</small>
</div>
<button
type="button"
aria-expanded={!noteContentCollapsed}
onClick={() => setNoteContentCollapsed(false)}
>
</button>
</div>
<div className="note-document-content">
<div className="note-document-meta"> <div className="note-document-meta">
<span> {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span> <span> {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
<span> {selected.source_row ?? "—"}</span> <span> {selected.source_row ?? "—"}</span>
</div> </div>
{selected.publish_url && (
<button
type="button"
className="mobile-note-collapse-trigger"
onClick={() => setNoteContentCollapsed(true)}
>
</button>
)}
<div className="note-title-row"> <div className="note-title-row">
<h1>{selected.title}</h1> <h1>{selected.title}</h1>
<button <button
@@ -1183,9 +1237,10 @@ export default function Home() {
</div> </div>
</section> </section>
)} )}
</div>
</article> </article>
<form className="submit-card" onSubmit={submitNote}> <form ref={submitCardRef} className="submit-card" onSubmit={submitNote}>
<div className="submit-heading"> <div className="submit-heading">
<div> <div>
<p className="micro"></p> <p className="micro"></p>

View File

@@ -18,12 +18,13 @@ test("builds the branded external task shell", async () => {
}); });
test("keeps claiming minimal and backfill one-to-one", async () => { test("keeps claiming minimal and backfill one-to-one", async () => {
const [page, layout, packageJson, nextConfig] = const [page, layout, packageJson, nextConfig, styles] =
await Promise.all([ await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"), readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"), readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"), readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../next.config.ts", import.meta.url), "utf8"), readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]); ]);
assert.match(page, /微信号\s*\/\s*手机号/); assert.match(page, /微信号\s*\/\s*手机号/);
@@ -53,6 +54,13 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /action:\s*"recover"/); assert.match(page, /action:\s*"recover"/);
assert.match(page, /同一任务多次领取会分批展示/); assert.match(page, /同一任务多次领取会分批展示/);
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/); assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
assert.match(page, /笔记内容已收起/);
assert.match(page, /展开笔记内容/);
assert.match(page, /收起笔记内容/);
assert.match(page, /isFirstBackfill/);
assert.match(page, /scrollIntoView/);
assert.match(page, /matchMedia\("\(max-width: 620px\)"\)/);
assert.match(styles, /\.note-document\.mobile-collapsed/);
assert.doesNotMatch(page, /批量回填/); assert.doesNotMatch(page, /批量回填/);
assert.doesNotMatch(page, /复制标题和正文/); assert.doesNotMatch(page, /复制标题和正文/);
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/); assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
@@ -129,6 +137,9 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
assert.match(page, /action:\s*"revoke_delegation"/); assert.match(page, /action:\s*"revoke_delegation"/);
assert.match(page, /合作社转派 · 无需登录/); assert.match(page, /合作社转派 · 无需登录/);
assert.match(page, /"X-KOC-Delegation"/); assert.match(page, /"X-KOC-Delegation"/);
assert.match(page, /const url = new URL\(window\.location\.href\)/);
assert.match(page, /url\.search = ""/);
assert.doesNotMatch(page, /const url = new URL\(window\.location\.origin\)/);
assert.match(page, /url\.searchParams\.set\("share", shareToken\)/); assert.match(page, /url\.searchParams\.set\("share", shareToken\)/);
assert.match(page, /请保存当前分享链接/); assert.match(page, /请保存当前分享链接/);
assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/); assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/);

View File

@@ -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( function dueSchedules(
startDate: string, startDate: string,
days: number[], days: number[],
timestamp: number, timestamp: number,
) { ) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const currentHour = shanghaiHourFromTimestamp(timestamp);
return days return days
.map((scheduleDay) => ({ .map((scheduleDay) => ({
scheduleDay, scheduleDay,
@@ -78,8 +88,7 @@ function dueSchedules(
): value is { scheduleDay: number; scheduledDate: string } => ): value is { scheduleDay: number; scheduledDate: string } =>
Boolean( Boolean(
value.scheduledDate && value.scheduledDate &&
(value.scheduledDate < currentDate || isCollectionScheduleDue(value.scheduledDate, timestamp),
(value.scheduledDate === currentDate && currentHour >= 10)),
), ),
); );
} }
@@ -131,8 +140,8 @@ export async function createCollectionRunTasks(
distribution.id, distribution.id,
scheduledDate, scheduledDate,
scheduleDay, scheduleDay,
`${scheduledDate}T10:00:00+08:00`, `${scheduledDate}T09:00:00+08:00`,
`等待第${scheduleDay}10:00自动采集`, `等待第${scheduleDay}天 09:00自动采集`,
), ),
); );
} }
@@ -162,7 +171,7 @@ export async function collectDistributionMetrics(
if (!current) throw new Error("分发记录不存在"); if (!current) throw new Error("分发记录不存在");
if (!current.publish_url) 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"); const runId = uid("run");
await db await db
.prepare( .prepare(
@@ -231,7 +240,7 @@ export async function collectDistributionMetrics(
const dayWeight = scheduleDay ?? 1; const dayWeight = scheduleDay ?? 1;
const successDescription = const successDescription =
source === "automatic" source === "automatic"
? `成功 · 第${dayWeight}10:00自动采集` ? `成功 · 第${dayWeight}天 09:00自动采集`
: source === "catchup" : source === "catchup"
? `成功 · 第${dayWeight}天自动追采` ? `成功 · 第${dayWeight}天自动追采`
: "成功 · 手动采集"; : "成功 · 手动采集";

View File

@@ -155,6 +155,23 @@ export class DatabaseClient {
connection.release(); connection.release();
} }
} }
async transaction<T>(operation: (database: DatabaseClient) => Promise<T>) {
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 { declare global {

View File

@@ -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<ReleasableDistribution>();
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,
};
});
}

View File

@@ -284,7 +284,7 @@ export async function setCollectionPlan(
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END, collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND COALESCE(publish_url, '') != ''`, 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 created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
const catchup = await runDueScheduledCollections( const catchup = await runDueScheduledCollections(

View File

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

View File

@@ -11,12 +11,17 @@ export type ResourceImportRow = {
profileUrl: string; profileUrl: string;
ipLocation: string; ipLocation: string;
followers: number; followers: number;
followersResolved: boolean;
cooperationSource: string; cooperationSource: string;
errors: string[]; errors: string[];
}; };
const HEADER_ALIASES = { const HEADER_ALIASES = {
profileUrl: ["账号主页", "账号主页链接", "主页链接", "账号链接"], profileUrl: ["账号链接", "账号主页", "账号主页链接", "主页链接"],
nickname: ["账号昵称", "账号名称", "昵称"],
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
followers: ["粉丝数", "粉丝", "粉丝量"],
cooperationSource: ["合作来源", "资源来源", "历史合作来源"], cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
} as const; } as const;
@@ -120,7 +125,10 @@ function parseCsv(text: string) {
} }
function normalizeHeader(value: string) { function normalizeHeader(value: string) {
return value.replace(/[\s_\-()]/g, "").toLocaleLowerCase("zh-CN"); return value
.replace(/[\s_\-()]/g, "")
.replace(/必填|选填/g, "")
.toLocaleLowerCase("zh-CN");
} }
function canonicalHeader(value: string): CanonicalHeader | null { function canonicalHeader(value: string): CanonicalHeader | null {
@@ -186,6 +194,40 @@ function valueAt(row: string[], mapping: Map<CanonicalHeader, number>, key: Cano
return index === undefined ? "" : String(row[index] ?? "").trim(); return index === undefined ? "" : String(row[index] ?? "").trim();
} }
export function parseResourceFollowers(value: string) {
const normalized = value.trim().replace(/[,\s]/g, "").replace(/\+$/, "");
if (!normalized) return { value: 0, resolved: false, valid: true };
const match = normalized.match(/^(\d+(?:\.\d+)?)(万|w|W|千|k|K)?$/);
if (!match) return { value: 0, resolved: false, valid: false };
const multiplier =
match[2] === "万" || match[2]?.toLowerCase() === "w"
? 10_000
: match[2] === "千" || match[2]?.toLowerCase() === "k"
? 1_000
: 1;
return {
value: Math.round(Number(match[1]) * multiplier),
resolved: true,
valid: true,
};
}
export function resourceImportMissingFields(
row: Pick<
ResourceImportRow,
"nickname" | "publicAccountId" | "ipLocation" | "followersResolved"
>,
) {
const missing: string[] = [];
if (!row.nickname.trim()) missing.push("nickname");
if (!row.publicAccountId.trim()) missing.push("publicAccountId");
if (!row.ipLocation.trim() || row.ipLocation.trim() === "待识别") {
missing.push("ipLocation");
}
if (!row.followersResolved) missing.push("followers");
return missing;
}
function normalizeRows(rows: string[][]) { function normalizeRows(rows: string[][]) {
const header = findHeader(rows); const header = findHeader(rows);
if (!header) { if (!header) {
@@ -198,18 +240,24 @@ function normalizeRows(rows: string[][]) {
const rawProfileUrl = valueAt(source, header.mapping, "profileUrl"); const rawProfileUrl = valueAt(source, header.mapping, "profileUrl");
const profileUrl = normalizeProfileUrl(rawProfileUrl); const profileUrl = normalizeProfileUrl(rawProfileUrl);
const platform = platformFromProfileUrl(profileUrl); const platform = platformFromProfileUrl(profileUrl);
const rawFollowers = valueAt(source, header.mapping, "followers");
const parsedFollowers = parseResourceFollowers(rawFollowers);
const errors: string[] = []; const errors: string[] = [];
if (!rawProfileUrl) errors.push("账号主页不能为空"); if (!rawProfileUrl) errors.push("账号主页不能为空");
else if (!profileUrl) errors.push("账号主页链接格式不正确"); else if (!profileUrl) errors.push("账号主页链接格式不正确");
else if (!platform) errors.push("当前自动解析仅支持小红书账号主页"); else if (!platform) errors.push("当前自动解析仅支持小红书账号主页");
if (!parsedFollowers.valid) {
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
}
result.push({ result.push({
rowNumber: index + 1, rowNumber: index + 1,
platform, platform,
nickname: "", nickname: valueAt(source, header.mapping, "nickname"),
publicAccountId: "", publicAccountId: valueAt(source, header.mapping, "publicAccountId"),
profileUrl, profileUrl,
ipLocation: "待识别", ipLocation: valueAt(source, header.mapping, "ipLocation"),
followers: 0, followers: parsedFollowers.value,
followersResolved: parsedFollowers.resolved,
cooperationSource: valueAt(source, header.mapping, "cooperationSource"), cooperationSource: valueAt(source, header.mapping, "cooperationSource"),
errors, errors,
}); });

View File

@@ -33,11 +33,11 @@ export function startScheduler() {
if (!isEnabled(getRuntimeEnv().ENABLE_SCHEDULER, true)) return; if (!isEnabled(getRuntimeEnv().ENABLE_SCHEDULER, true)) return;
if (globalThis.__kocLoopScheduler) return; if (globalThis.__kocLoopScheduler) return;
globalThis.__kocLoopScheduler = cron.schedule( globalThis.__kocLoopScheduler = cron.schedule(
"0 10 * * *", "0 9 * * *",
() => void runDailyJob().catch((error) => { () => void runDailyJob().catch((error) => {
console.error("[KOC LOOP] daily scheduler failed", error); console.error("[KOC LOOP] daily scheduler failed", error);
}), }),
{ timezone: "Asia/Shanghai", noOverlap: true }, { 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");
} }

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

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

Binary file not shown.

View File

@@ -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"],
);
});

View File

@@ -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<T>/);
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"/);
});

View File

@@ -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, /读取表格/);
assert.match(adminApp, /当前仅展示/); assert.match(adminApp, /当前仅展示/);
assert.match(adminApp, /release_distribution/);
assert.match(adminApp, /确认释放这篇笔记/);
assert.equal(snapshot.sheetId, "954953"); assert.equal(snapshot.sheetId, "954953");
assert.equal(snapshot.rows.length, 61); assert.equal(snapshot.rows.length, 61);
assert.equal( assert.equal(
@@ -229,6 +231,11 @@ test("supports task collection schedules and latest public metrics", async () =>
} }
assert.match(adminApp, /选择开始日期与采集日/); assert.match(adminApp, /选择开始日期与采集日/);
assert.match(adminApp, /第1天到第7天/); 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, /内容 \/ 发布账号/);
assert.match(adminApp, /recovery-title-link/); assert.match(adminApp, /recovery-title-link/);
assert.match(adminApp, /打开小红书笔记/); assert.match(adminApp, /打开小红书笔记/);
@@ -252,10 +259,11 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.match(collectionService, /runDueScheduledCollections/); assert.match(collectionService, /runDueScheduledCollections/);
assert.match(collectionService, /retryFailedCollections/); assert.match(collectionService, /retryFailedCollections/);
assert.match(collectionService, /exposure IS NOT NULL AND views IS NOT NULL/); 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.doesNotMatch(collectionService, /latestDueSchedule/);
assert.match(collectionService, /自动追采/); assert.match(collectionService, /自动追采/);
assert.match(scheduler, /0 10 \* \* \*/); assert.match(scheduler, /0 9 \* \* \*/);
assert.match(scheduler, /backfillAccountProfiles/); assert.match(scheduler, /backfillAccountProfiles/);
assert.match(scheduler, /Asia\/Shanghai/); assert.match(scheduler, /Asia\/Shanghai/);
assert.match(bootstrapRoute, /backfillAccountProfiles/); assert.match(bootstrapRoute, /backfillAccountProfiles/);

View File

@@ -4,7 +4,9 @@ import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
import { import {
mergeCooperationSources, mergeCooperationSources,
normalizeProfileUrl, normalizeProfileUrl,
parseResourceFollowers,
parseResourceImportFile, parseResourceImportFile,
resourceImportMissingFields,
resourcePlatformUid, resourcePlatformUid,
} from "../lib/resource-import.ts"; } from "../lib/resource-import.ts";
@@ -21,14 +23,50 @@ test("parses CSV resources and normalizes public profile data", () => {
nickname: "", nickname: "",
publicAccountId: "", publicAccountId: "",
profileUrl: "https://www.xiaohongshu.com/user/profile/abc123", profileUrl: "https://www.xiaohongshu.com/user/profile/abc123",
ipLocation: "待识别", ipLocation: "",
followers: 0, followers: 0,
followersResolved: false,
cooperationSource: "林林KOC社群", cooperationSource: "林林KOC社群",
errors: [], errors: [],
}); });
assert.equal(resourcePlatformUid(rows[0]), "abc123"); assert.equal(resourcePlatformUid(rows[0]), "abc123");
}); });
test("uses optional account fields directly and only requires the profile URL", () => {
const csv = [
"账号链接,账号昵称,账号ID,IP属地,粉丝数,合作来源",
"https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,历史资源",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.equal(row.nickname, "番茄不炒蛋");
assert.equal(row.publicAccountId, "4171542126");
assert.equal(row.ipLocation, "江西");
assert.equal(row.followers, 10);
assert.equal(row.followersResolved, true);
assert.deepEqual(resourceImportMissingFields(row), []);
});
test("normalizes common follower formats and identifies missing enrichment fields", () => {
assert.deepEqual(parseResourceFollowers("1.3万"), {
value: 13_000,
resolved: true,
valid: true,
});
assert.deepEqual(parseResourceFollowers("10+"), {
value: 10,
resolved: true,
valid: true,
});
assert.deepEqual(parseResourceFollowers(""), {
value: 0,
resolved: false,
valid: true,
});
});
test("parses the first matching worksheet from an XLSX workbook", () => { test("parses the first matching worksheet from an XLSX workbook", () => {
const workbook = buildRecoveryWorkbook({ const workbook = buildRecoveryWorkbook({
sheetName: "KOC资源导入", sheetName: "KOC资源导入",
@@ -45,6 +83,7 @@ test("parses the first matching worksheet from an XLSX workbook", () => {
assert.equal(rows[0].platform, "小红书"); assert.equal(rows[0].platform, "小红书");
assert.equal(rows[0].cooperationSource, "存量资源包"); assert.equal(rows[0].cooperationSource, "存量资源包");
assert.equal(rows[0].errors.length, 0); assert.equal(rows[0].errors.length, 0);
assert.equal(rows[0].followersResolved, false);
}); });
test("reports invalid required fields without hiding valid rows", () => { test("reports invalid required fields without hiding valid rows", () => {
@@ -54,6 +93,18 @@ test("reports invalid required fields without hiding valid rows", () => {
assert.match(rows[1].errors.join(""), /仅支持小红书账号主页/); assert.match(rows[1].errors.join(""), /仅支持小红书账号主页/);
}); });
test("rejects invalid optional follower values without requiring other optional fields", () => {
const csv = [
"账号链接,粉丝数",
"https://www.xiaohongshu.com/user/profile/abc123,很多",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.match(row.errors.join(""), /粉丝数格式不正确/);
});
test("normalizes profile URLs and merges cooperation sources", () => { test("normalizes profile URLs and merges cooperation sources", () => {
assert.equal( assert.equal(
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"), normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),