6 Commits

Author SHA1 Message Date
巫凤萍
1766a90cc1 feat: add release controls and 09:00 collection 2026-08-12 11:43:26 +08:00
巫凤萍
1f1887c860 feat: optimize user management and add account deletion 2026-08-07 17:08:56 +08:00
巫凤萍
5670a7939b Expand KOC LOOP MCP operations 2026-08-07 14:12:52 +08:00
巫凤萍
1a9a113229 Add MCP task creation endpoint 2026-08-07 11:59:45 +08:00
巫凤萍
bd9b0c3871 Fix password hashing for Workers runtime 2026-08-07 11:20:31 +08:00
巫凤萍
c926a6a874 Add user authentication and role management 2026-08-07 11:06:20 +08:00
44 changed files with 6325 additions and 248 deletions

View File

@@ -1,6 +1,8 @@
KOC_PORTAL_URL=http://localhost:3000 KOC_PORTAL_URL=http://localhost:3000
ADMIN_ALLOWED_EMAIL=operator@example.com SUPER_ADMIN_USERNAME=admin
SUPER_ADMIN_PASSWORD=qazxsw123admin
ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
KOC_MCP_API_KEY=replace-with-a-separate-long-random-secret
# Optional override. The production key must be stored as a runtime secret. # Optional override. The production key must be stored as a runtime secret.
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp

108
README.md
View File

@@ -22,6 +22,15 @@ npm run build
飞书自建应用需开通电子表格读取、知识库节点读取和云文档素材下载权限, 飞书自建应用需开通电子表格读取、知识库节点读取和云文档素材下载权限,
并将应用添加到目标知识库或电子表格的文档应用中。 并将应用添加到目标知识库或电子表格的文档应用中。
后台登录首次启动还需要配置:
- `SUPER_ADMIN_USERNAME`:唯一的超级管理员登录账号
- `SUPER_ADMIN_PASSWORD`:超级管理员初始密码,至少 8 位
- `ADMIN_INTERNAL_TOKEN`:自动采集等内部任务使用的服务密钥
- `KOC_MCP_API_KEY`Agent 调用 KOC LOOP MCP 使用的独立 Bearer 密钥
系统首次登录时创建唯一的超级管理员。后续管理员和普通用户均由“用户管理”页面创建,普通用户不能访问 KOC 资源库。
This starter does not use `wrangler.jsonc`. This starter does not use `wrangler.jsonc`.
## Included Shape ## Included Shape
@@ -33,63 +42,70 @@ This starter does not use `wrangler.jsonc`.
- `examples/d1/` contains an optional D1 example surface - `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed - `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers ## 后台账号与角色
OpenAI workspace sites can read the current user's email from - 超级管理员:唯一系统管理员,可管理管理员和普通用户。
`oai-authenticated-user-email`. - 管理员:可访问全部业务模块,可创建和重置普通用户账号。
- 普通用户:可使用工作台、任务、内容分发和数据回收,不可查看或导出 KOC 资源库。
SIWC-authenticated workspace sites may also receive 后台不提供注册、找回密码和普通用户个人改密。密码重置统一由管理员在后台完成。
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent: ## Agent MCP
```tsx 生产地址:
import { headers } from "next/headers";
export default async function Home() { ```text
const requestHeaders = await headers(); https://你的-KOC-LOOP-后台域名/api/mcp
const email = requestHeaders.get("oai-authenticated-user-email"); ```
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email; MCP 使用独立的 `KOC_MCP_API_KEY` 鉴权,请通过请求头发送:
// ...
```text
Authorization: Bearer <KOC_MCP_API_KEY>
```
创建分发任务使用 `create_distribution_task`,参数如下:
- `feishu_url`:飞书 Wiki 或电子表格链接;多工作表时必须带目标 `sheet` 参数
- `task_name`:任务名称
- `due_date`:北京时间截止日期,格式 `YYYY-MM-DD`
- `brand_project`:可选,品牌或项目名称;未提供时记录为“未设置项目”
成功后返回任务 ID、笔记数量和 KOC 领取链接。完全相同的任务参数重复调用时,返回已经存在的任务,避免 Agent 重试产生重复任务。
同时开放以下运营工具:
- 任务:`task_list``task_get`
- 数据回收:`recovery_list``recovery_export`
- 数据采集:`collection_plan_set``collection_run_due``collection_collect_now``collection_retry_failed`
- KOC 资源:`resource_search``resource_get``resource_backfill_profile``resource_export`
`recovery_export``resource_export` 返回 15 分钟有效的安全下载链接。链接不包含 MCP 密钥;任务数据导出会继续把笔记原图、发布截图和创作者截图直接嵌入 Excel。
在支持远程 MCP 的 Agent 中添加:
```json
{
"mcpServers": {
"koc-loop": {
"url": "https://你的-KOC-LOOP-后台域名/api/mcp",
"headers": {
"Authorization": "Bearer ${KOC_LOOP_MCP_API_KEY}"
}
}
}
} }
``` ```
## Optional Dispatch-Owned ChatGPT Sign-In 使用示例:
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs ```text
optional or required ChatGPT sign-in: 用这个飞书表格创建发布任务:<飞书链接>。
任务名“8月骑手招募”截止时间 2026-08-20。
创建后把 KOC 领取链接发给我。
```
- Use `getChatGPTUser()` for optional signed-in UI. 密钥不要写入仓库、对话内容或 URL 查询参数,生产环境通过站点密钥管理配置。
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands ## Useful Commands

View File

@@ -12,6 +12,12 @@ import {
formatShanghaiDate as formatDate, formatShanghaiDate as formatDate,
formatShanghaiToday, formatShanghaiToday,
} from "../lib/date-utils"; } 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 = { type Partner = {
id: string; id: string;
@@ -104,6 +110,14 @@ type DashboardData = {
portal_url: string; portal_url: string;
}; };
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "comments"
| "total"
| "collection_updated_at";
type FeishuPreview = { type FeishuPreview = {
sheetId: string; sheetId: string;
sheetName: string; sheetName: string;
@@ -122,7 +136,8 @@ type NavKey =
| "tasks" | "tasks"
| "distributions" | "distributions"
| "resources" | "resources"
| "recovery"; | "recovery"
| "users";
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" }, { key: "overview", label: "工作台", mark: "⌂" },
@@ -130,6 +145,7 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "distributions", label: "内容分发", mark: "↗" }, { key: "distributions", label: "内容分发", mark: "↗" },
{ key: "resources", label: "KOC资源", mark: "◎" }, { key: "resources", label: "KOC资源", mark: "◎" },
{ key: "recovery", label: "数据回收", mark: "◫" }, { key: "recovery", label: "数据回收", mark: "◫" },
{ key: "users", label: "用户管理", mark: "♙" },
]; ];
const EMPTY_DATA: DashboardData = { const EMPTY_DATA: DashboardData = {
@@ -203,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) { function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null; return distribution.exposure !== null && distribution.views !== null;
} }
@@ -301,7 +379,7 @@ async function compressScreenshot(file: File) {
}); });
} }
export default function Home() { export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [data, setData] = useState<DashboardData>(EMPTY_DATA); const [data, setData] = useState<DashboardData>(EMPTY_DATA);
const [activeNav, setActiveNav] = useState<NavKey>("overview"); const [activeNav, setActiveNav] = useState<NavKey>("overview");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -322,6 +400,12 @@ export default function Home() {
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null); const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
const [exportingResources, setExportingResources] = useState(false); const [exportingResources, setExportingResources] = useState(false);
const isManager = currentUser.role === "super_admin" || currentUser.role === "admin";
const visibleNavItems = NAV_ITEMS.filter((item) => {
if (item.key === "resources" && currentUser.role === "user") return false;
if (item.key === "users" && !isManager) return false;
return true;
});
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
@@ -399,6 +483,8 @@ export default function Home() {
const recentDistributions = data.distributions.slice(0, 6); const recentDistributions = data.distributions.slice(0, 6);
const navigate = (key: NavKey) => { const navigate = (key: NavKey) => {
if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return;
setActiveNav(key); setActiveNav(key);
setMenuOpen(false); setMenuOpen(false);
}; };
@@ -503,7 +589,7 @@ export default function Home() {
startDate, startDate,
days, days,
}, },
`采集任务已创建,将在所选日期10:00自动执行`, `采集任务已创建将在所选日期09:00自动执行`,
); );
}; };
@@ -646,6 +732,15 @@ export default function Home() {
title: "数据回收", title: "数据回收",
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
}, },
users: {
title: "用户管理",
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
},
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
window.location.assign("/login");
}; };
return ( return (
@@ -661,7 +756,7 @@ export default function Home() {
<nav className="main-nav" aria-label="主导航"> <nav className="main-nav" aria-label="主导航">
<p className="nav-caption"></p> <p className="nav-caption"></p>
{NAV_ITEMS.map((item) => ( {visibleNavItems.map((item) => (
<button <button
key={item.key} key={item.key}
className={activeNav === item.key ? "active" : ""} className={activeNav === item.key ? "active" : ""}
@@ -683,14 +778,14 @@ export default function Home() {
<span>{stats.activeTasks} </span> <span>{stats.activeTasks} </span>
</div> </div>
</div> </div>
<div className="sidebar-user"> <button className="sidebar-user" onClick={() => void logout()} title="退出登录">
<div className="avatar mint"></div> <div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
<div> <div>
<strong></strong> <strong>{currentUser.username}</strong>
<span></span> <span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
</div>
<span className="chevron"></span>
</div> </div>
<span className="chevron">退</span>
</button>
</aside> </aside>
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />} {menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
@@ -750,6 +845,7 @@ export default function Home() {
distributions={recentDistributions} distributions={recentDistributions}
tasks={data.tasks} tasks={data.tasks}
onNavigate={navigate} onNavigate={navigate}
showResources={currentUser.role !== "user"}
/> />
)} )}
{activeNav === "tasks" && ( {activeNav === "tasks" && (
@@ -764,6 +860,17 @@ export default function Home() {
<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" && (
@@ -774,6 +881,9 @@ export default function Home() {
onExport={exportResources} onExport={exportResources}
/> />
)} )}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
{activeNav === "recovery" && ( {activeNav === "recovery" && (
<RecoveryPage <RecoveryPage
tasks={data.tasks} tasks={data.tasks}
@@ -942,6 +1052,7 @@ function OverviewPage({
distributions, distributions,
tasks, tasks,
onNavigate, onNavigate,
showResources,
}: { }: {
stats: { stats: {
resources: number; resources: number;
@@ -953,6 +1064,7 @@ function OverviewPage({
distributions: Distribution[]; distributions: Distribution[];
tasks: Task[]; tasks: Task[];
onNavigate: (key: NavKey) => void; onNavigate: (key: NavKey) => void;
showResources: boolean;
}) { }) {
const cards = [ const cards = [
{ {
@@ -979,7 +1091,7 @@ function OverviewPage({
hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成", hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成",
tone: "orange", tone: "orange",
}, },
]; ].filter((card) => showResources || card.label !== "已沉淀账号资源");
return ( return (
<> <>
<div className="stat-grid"> <div className="stat-grid">
@@ -1031,7 +1143,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>
@@ -1251,11 +1363,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 (
@@ -1298,8 +1417,69 @@ function DistributionPage({
<div><h2></h2><p></p></div> <div><h2></h2><p></p></div>
<div className="filter-chips"><button className="active"></button><button></button><button></button></div> <div className="filter-chips"><button className="active"></button><button></button><button></button></div>
</div> </div>
<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>
); );
} }
@@ -1307,9 +1487,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">
@@ -1322,6 +1504,7 @@ function DistributionTable({
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
{onRelease && <th></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1355,6 +1538,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 ? (
<button
type="button"
className="release-distribution-button"
onClick={() => onRelease(item)}
>
</button>
) : (
<span className="muted"></span>
)}
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -1634,6 +1832,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 (
@@ -1658,6 +1860,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
@@ -1704,19 +1923,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;
@@ -1879,7 +2134,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

@@ -19,12 +19,17 @@ import {
type CollectionMcpBindings, type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client"; } from "../../../lib/mcp-collection-client";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { isManagerRequest } from "../../../lib/user-auth";
import { import {
FeishuSourceError, FeishuSourceError,
readFeishuSource, readFeishuSource,
type FeishuBindings, type FeishuBindings,
type FeishuSource,
} from "../../../lib/feishu-client"; } from "../../../lib/feishu-client";
import { createDistributionTask } from "../../../lib/task-service";
import {
DistributionReleaseError,
releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service";
export const runtime = "edge"; export const runtime = "edge";
@@ -38,78 +43,8 @@ function numberValue(value: unknown, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
async function createTaskFromSource(
source: FeishuSource,
name: string,
brand: string,
dueAt: string,
) {
const db = getRawDb();
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
await db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
name,
brand,
source.rows.length,
dueAt,
source.url,
source.sheetId,
source.sheetName,
source.syncedAt,
shareToken,
)
.run();
try {
const contentStatements = source.rows.map((row) => {
const contentId = uid("content");
const imageAssets = row.images.map((image) => ({
...image,
key: `content-assets/${taskId}/${contentId}/${image.index}`,
}));
return db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
)
.bind(
contentId,
taskId,
row.title,
row.body,
JSON.stringify(imageAssets),
`飞书 · ${source.sheetName}`,
row.sourceRow,
);
});
for (let index = 0; index < contentStatements.length; index += 100) {
await db.batch(contentStatements.slice(index, index + 100));
}
await db
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
.bind(taskId)
.run();
} catch (error) {
await db.batch([
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
]);
throw error;
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const body = (await request.json()) as ActionBody; const body = (await request.json()) as ActionBody;
@@ -140,11 +75,15 @@ export async function POST(request: Request) {
{ status: 400 }, { status: 400 },
); );
} }
const source = await readFeishuSource( await createDistributionTask(
String(body.feishuUrl ?? "").trim(), {
feishuUrl: String(body.feishuUrl ?? "").trim(),
name,
brand,
dueAt,
},
env as unknown as FeishuBindings, env as unknown as FeishuBindings,
); );
await createTaskFromSource(source, name, brand, dueAt);
} else if (body.action === "claim") { } else if (body.action === "claim") {
const partnerId = String(body.partnerId ?? ""); const partnerId = String(body.partnerId ?? "");
const taskId = String(body.taskId ?? ""); const taskId = String(body.taskId ?? "");
@@ -188,6 +127,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();
@@ -245,7 +190,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,
), ),
]); ]);
@@ -388,7 +333,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

@@ -0,0 +1,47 @@
import {
createSession,
createSessionCookie,
ensureInitialSuperAdmin,
normalizeUsername,
verifyPassword,
} from "../../../../lib/user-auth";
import { getRawDb } from "../../../../lib/mvp-db";
export const runtime = "edge";
export async function POST(request: Request) {
try {
await ensureInitialSuperAdmin();
const body = (await request.json()) as { username?: string; password?: string };
const username = normalizeUsername(body.username);
const password = String(body.password ?? "");
const user = await getRawDb()
.prepare(
`SELECT id, username, role, password_hash, password_salt, password_iterations
FROM users WHERE username = ? LIMIT 1`,
)
.bind(username)
.first<{
id: string;
username: string;
role: string;
password_hash: string;
password_salt: string;
password_iterations: number;
}>();
if (!user || !(await verifyPassword(password, user))) {
return Response.json({ error: "账号或密码错误" }, { status: 401 });
}
const token = await createSession(user.id);
const secure = new URL(request.url).protocol === "https:";
return Response.json(
{ user: { id: user.id, username: user.username, role: user.role } },
{ headers: { "Set-Cookie": createSessionCookie(token, secure) } },
);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "登录失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,17 @@
import {
clearSessionCookie,
deleteSession,
sessionCookieFromHeader,
} from "../../../../lib/user-auth";
export const runtime = "edge";
export async function POST(request: Request) {
const token = sessionCookieFromHeader(request.headers.get("cookie"));
await deleteSession(token);
const secure = new URL(request.url).protocol === "https:";
return Response.json(
{ loggedOut: true },
{ headers: { "Set-Cookie": clearSessionCookie(secure) } },
);
}

View File

@@ -12,12 +12,13 @@ import {
getRawDb, getRawDb,
seedIfEmpty, seedIfEmpty,
} from "../../../lib/mvp-db"; } from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
export const runtime = "edge"; export const runtime = "edge";
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); const principal = await getRequestPrincipal(request);
if (!principal) return authForbidden();
try { try {
await ensureSchema(); await ensureSchema();
await seedIfEmpty(); await seedIfEmpty();
@@ -60,7 +61,12 @@ export async function GET(request: Request) {
} else { } else {
await catchup; await catchup;
} }
return Response.json(await getDashboardData()); const dashboard = await getDashboardData();
return Response.json(
principal.kind === "user" && principal.user.role === "user"
? { ...dashboard, accounts: [] }
: dashboard,
);
} catch (error) { } catch (error) {
return Response.json( return Response.json(
{ error: error instanceof Error ? error.message : "加载失败" }, { error: error instanceof Error ? error.message : "加载失败" },

View File

@@ -5,7 +5,7 @@ import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
export const runtime = "edge"; export const runtime = "edge";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const form = await request.formData(); const form = await request.formData();

View File

@@ -8,7 +8,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge"; export const runtime = "edge";
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const distributionId = new URL(request.url).searchParams const distributionId = new URL(request.url).searchParams

234
app/api/mcp/route.ts Normal file
View File

@@ -0,0 +1,234 @@
import { env } from "cloudflare:workers";
import {
createMcpHandler,
McpServer,
type McpRequestContext,
} from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import {
FeishuSourceError,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
buildClaimUrl,
createDistributionTask,
} from "../../../lib/task-service";
import { registerMcpOperationTools } from "../../../lib/mcp-tools";
import type { CollectionMcpBindings } from "../../../lib/mcp-collection-client";
export const runtime = "edge";
type McpBindings = FeishuBindings & CollectionMcpBindings & {
KOC_MCP_API_KEY?: string;
KOC_PORTAL_URL?: string;
};
const toolOutputSchema = z.object({
created: z.boolean(),
task_id: z.string(),
task_name: z.string(),
brand_project: z.string(),
due_date: z.string(),
sheet_name: z.string(),
note_count: z.number().int().nonnegative(),
claim_url: z.string().url(),
});
function getBindings() {
return env as unknown as McpBindings;
}
function createServer(context: McpRequestContext) {
const bindings = getBindings();
const origin = context.requestInfo
? new URL(context.requestInfo.url).origin
: "";
const server = new McpServer(
{ name: "koc-loop", version: "2.0.0" },
{
instructions:
"用于创建和管理 KOC 内容分发任务、数据回收、公开数据采集与账号资源。创建任务前确认飞书链接、任务名和北京时间截止日期;写操作应先向用户说明影响。相同参数的任务创建和采集计划设置支持安全重试。",
},
);
server.registerTool(
"create_distribution_task",
{
title: "创建 KOC 分发任务",
description:
"读取飞书电子表格中的标题、正文和配图,在 KOC LOOP 创建分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
inputSchema: z.object({
feishu_url: z
.string()
.url()
.describe("飞书 Wiki 或电子表格链接,建议包含目标 sheet 参数"),
task_name: z.string().min(1).max(100).describe("分发任务名称"),
due_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe("北京时间截止日期,格式为 YYYY-MM-DD"),
brand_project: z
.string()
.min(1)
.max(100)
.optional()
.describe("品牌或项目名称;未提供时系统记录为“未设置项目”"),
}),
outputSchema: toolOutputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ feishu_url, task_name, due_date, brand_project }) => {
try {
const bindings = getBindings();
const portalUrl = String(bindings.KOC_PORTAL_URL ?? "").trim();
if (!portalUrl) {
throw new Error("KOC 领取站点地址尚未配置");
}
const result = await createDistributionTask(
{
feishuUrl: feishu_url,
name: task_name,
brand: brand_project?.trim() || "未设置项目",
dueAt: due_date,
},
bindings,
{ deduplicate: true },
);
const output = {
created: result.created,
task_id: result.taskId,
task_name: result.name,
brand_project: result.brand,
due_date: result.dueAt,
sheet_name: result.sheetName,
note_count: result.noteCount,
claim_url: buildClaimUrl(portalUrl, result.shareToken),
};
const actionText = result.created ? "已创建" : "已找到相同任务";
return {
content: [
{
type: "text",
text: `${actionText}${result.name}”,共 ${result.noteCount} 篇笔记。领取链接:${output.claim_url}`,
},
],
structuredContent: output,
};
} catch (error) {
const message =
error instanceof FeishuSourceError
? error.message
: error instanceof Error &&
[
"KOC 领取站点地址尚未配置",
"截止日期必须使用 YYYY-MM-DD 格式",
"截止日期无效",
"请补全飞书链接、任务名称和品牌/项目",
].includes(error.message)
? error.message
: "创建任务失败,请稍后重试或联系系统管理员";
return {
isError: true,
content: [{ type: "text", text: message }],
};
}
},
);
registerMcpOperationTools(server, { bindings, origin });
return server;
}
const mcpHandler = createMcpHandler(createServer, {
legacy: "stateless",
responseMode: "json",
});
async function secretDigest(value: string) {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
);
}
async function secretsMatch(received: string, expected: string) {
const [left, right] = await Promise.all([
secretDigest(received),
secretDigest(expected),
]);
let difference = left.length ^ right.length;
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
}
return difference === 0;
}
function responseHeaders(response: Response, request: Request) {
const headers = new Headers(response.headers);
const origin = request.headers.get("Origin");
if (origin) headers.set("Access-Control-Allow-Origin", origin);
headers.set("Vary", "Origin");
headers.set(
"Access-Control-Expose-Headers",
"Mcp-Session-Id, WWW-Authenticate",
);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function originRejected(request: Request) {
const origin = request.headers.get("Origin");
return Boolean(origin && origin !== new URL(request.url).origin);
}
async function authorize(request: Request) {
const expected = String(getBindings().KOC_MCP_API_KEY ?? "").trim();
const authorization = request.headers.get("Authorization") ?? "";
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (!expected || !match || !(await secretsMatch(match[1].trim(), expected))) {
return new Response("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Bearer realm="KOC LOOP MCP"' },
});
}
return null;
}
async function handle(request: Request) {
if (originRejected(request)) {
return new Response("Forbidden origin", { status: 403 });
}
const unauthorized = await authorize(request);
if (unauthorized) return responseHeaders(unauthorized, request);
return responseHeaders(await mcpHandler.fetch(request), request);
}
export async function OPTIONS(request: Request) {
if (originRejected(request)) {
return new Response("Forbidden origin", { status: 403 });
}
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin":
request.headers.get("Origin") ?? new URL(request.url).origin,
"Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id, Last-Event-ID, Mcp-Name, Mcp-Method",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
},
});
}
export const POST = handle;
export const GET = handle;
export const DELETE = handle;

View File

@@ -15,6 +15,7 @@ import {
type RecoveryWorkbookImage, type RecoveryWorkbookImage,
type RecoveryWorkbookRow, type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook"; } from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
export const runtime = "edge"; export const runtime = "edge";
@@ -218,10 +219,17 @@ function exactArrayBuffer(bytes: Uint8Array) {
} }
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try { try {
const url = new URL(request.url);
const token = url.searchParams.get("token")?.trim() || "";
const tokenPayload = token
? await consumeMcpExportToken(token, "recovery")
: null;
if (!tokenPayload && !(await isAdminRequest(request))) return adminForbidden();
await ensureSchema(); await ensureSchema();
const taskId = new URL(request.url).searchParams.get("task")?.trim() || ""; const taskId = tokenPayload
? String(tokenPayload.taskId ?? "").trim()
: url.searchParams.get("task")?.trim() || "";
if (!taskId) { if (!taskId) {
return Response.json({ error: "缺少导出任务" }, { status: 400 }); return Response.json({ error: "缺少导出任务" }, { status: 400 });
} }

View File

@@ -1,10 +1,11 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { parseStoredDate } from "../../../lib/date-utils"; import { parseStoredDate } from "../../../lib/date-utils";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db"; import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import { import {
buildRecoveryWorkbook, buildRecoveryWorkbook,
type RecoveryWorkbookRow, type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook"; } from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
export const runtime = "edge"; export const runtime = "edge";
@@ -47,21 +48,20 @@ function exactArrayBuffer(bytes: Uint8Array) {
return copy.buffer; return copy.buffer;
} }
export async function POST(request: Request) { function normalizeAccountIds(value: unknown) {
if (!isAdminRequest(request)) return adminForbidden(); if (!Array.isArray(value)) return [];
try { return [
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
}
const accountIds = [
...new Set( ...new Set(
body.accountIds value
.filter((value): value is string => typeof value === "string") .filter((item): item is string => typeof item === "string")
.map((value) => value.trim()) .map((item) => item.trim())
.filter(Boolean), .filter(Boolean),
), ),
].slice(0, 5000); ].slice(0, 5000);
}
async function exportAccounts(accountIds: string[]) {
try {
if (accountIds.length === 0) { if (accountIds.length === 0) {
return Response.json({ error: "当前筛选结果为空" }, { status: 400 }); return Response.json({ error: "当前筛选结果为空" }, { status: 400 });
} }
@@ -187,3 +187,28 @@ export async function POST(request: Request) {
); );
} }
} }
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
}
return exportAccounts(normalizeAccountIds(body.accountIds));
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "导出失败" },
{ status: 500 },
);
}
}
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token")?.trim() || "";
const payload = token
? await consumeMcpExportToken(token, "resources")
: null;
if (!payload) return managerForbidden();
return exportAccounts(normalizeAccountIds(payload.accountIds));
}

View File

@@ -10,7 +10,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge"; export const runtime = "edge";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const form = await request.formData(); const form = await request.formData();

164
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,164 @@
import {
createPasswordRecord,
getRequestPrincipal,
managerForbidden,
normalizeUsername,
type UserRole,
validatePassword,
validateUsername,
} from "../../../lib/user-auth";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
export const runtime = "edge";
type UserRow = {
id: string;
username: string;
role: UserRole;
created_at: string;
updated_at: string;
};
async function manager(request: Request) {
const principal = await getRequestPrincipal(request);
if (!principal || principal.kind !== "user") return null;
return ["super_admin", "admin"].includes(principal.user.role)
? principal.user
: null;
}
async function listUsers() {
const result = await getRawDb()
.prepare(
`SELECT id, username, role, created_at, updated_at
FROM users
ORDER BY CASE role WHEN 'super_admin' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END,
created_at ASC`,
)
.all<UserRow>();
return result.results;
}
export async function GET(request: Request) {
await ensureSchema();
if (!(await manager(request))) return managerForbidden();
return Response.json({ users: await listUsers() });
}
export async function POST(request: Request) {
await ensureSchema();
const currentUser = await manager(request);
if (!currentUser) return managerForbidden();
try {
const body = (await request.json()) as {
action?: string;
username?: string;
password?: string;
role?: UserRole;
userId?: string;
};
const db = getRawDb();
if (body.action === "create") {
const password = String(body.password ?? "");
if (!validatePassword(password)) {
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
}
const passwordRecord = await createPasswordRecord(password);
const username = normalizeUsername(body.username);
const role = body.role === "admin" ? "admin" : "user";
if (!validateUsername(username)) {
return Response.json(
{ error: "账号需为2—32位中文、字母、数字或 _ . @ + -" },
{ status: 400 },
);
}
if (currentUser.role !== "super_admin" && role !== "user") {
return managerForbidden();
}
await db
.prepare(
`INSERT INTO users
(id, username, password_hash, password_salt, password_iterations, role)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
crypto.randomUUID(),
username,
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
role,
)
.run();
} else if (body.action === "reset_password") {
const password = String(body.password ?? "");
if (!validatePassword(password)) {
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
}
const passwordRecord = await createPasswordRecord(password);
const target = await db
.prepare("SELECT id, role FROM users WHERE id = ?")
.bind(String(body.userId ?? ""))
.first<{ id: string; role: UserRole }>();
if (!target) {
return Response.json({ error: "没有找到这个账号" }, { status: 404 });
}
if (
target.role === "super_admin" &&
!(currentUser.role === "super_admin" && target.id === currentUser.id)
) {
return Response.json({ error: "不能修改其他超级管理员账号" }, { status: 403 });
}
if (currentUser.role !== "super_admin" && target.role !== "user") {
return managerForbidden();
}
await db.batch([
db
.prepare(
`UPDATE users SET
password_hash = ?, password_salt = ?, password_iterations = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
target.id,
),
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
]);
} else if (body.action === "delete") {
const target = await db
.prepare("SELECT id, role FROM users WHERE id = ?")
.bind(String(body.userId ?? ""))
.first<{ id: string; role: UserRole }>();
if (!target) {
return Response.json({ error: "没有找到这个账号" }, { status: 404 });
}
if (target.id === currentUser.id) {
return Response.json({ error: "不能删除当前登录账号" }, { status: 400 });
}
if (target.role === "super_admin") {
return Response.json({ error: "不能删除超级管理员账号" }, { status: 403 });
}
if (currentUser.role !== "super_admin" && target.role !== "user") {
return managerForbidden();
}
await db.batch([
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
db.prepare("DELETE FROM users WHERE id = ? AND role <> 'super_admin'").bind(target.id),
]);
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
return Response.json({ users: await listUsers() });
} catch (error) {
const message = error instanceof Error ? error.message : "操作失败";
return Response.json(
{ error: message.includes("UNIQUE") ? "这个登录账号已存在" : message },
{ status: message.includes("UNIQUE") ? 409 : 500 },
);
}
}

View File

@@ -79,6 +79,135 @@ body {
text-decoration: none; text-decoration: none;
} }
.login-page {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(0, 1.25fr) minmax(380px, 0.75fr);
gap: 28px;
align-items: stretch;
padding: 42px;
background: #eef2ee;
}
.login-brand,
.login-card {
border-radius: 28px;
}
.login-brand {
display: flex;
min-height: 620px;
flex-direction: column;
justify-content: center;
padding: clamp(42px, 7vw, 96px);
color: white;
background: linear-gradient(135deg, #102d25, #1f6f55);
box-shadow: var(--shadow);
}
.login-logo {
display: grid;
width: 54px;
height: 54px;
place-items: center;
border-radius: 16px;
color: #15553f;
background: #a9e4cc;
font-size: 23px;
font-weight: 800;
}
.login-brand p {
margin: 28px 0 6px;
color: #67d4aa;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
}
.login-brand h1 {
margin: 0;
font-size: clamp(42px, 6vw, 82px);
font-weight: 520;
letter-spacing: -0.06em;
}
.login-brand span {
margin-top: 20px;
color: rgb(255 255 255 / 0.64);
font-size: 15px;
}
.login-card {
display: flex;
width: 100%;
max-width: 520px;
flex-direction: column;
align-self: center;
justify-self: center;
padding: clamp(32px, 5vw, 58px);
border: 1px solid #dfe6e1;
background: white;
box-shadow: var(--shadow);
}
.login-card h2 {
margin: 6px 0 4px;
font-size: 32px;
letter-spacing: -0.04em;
}
.login-tip {
margin-bottom: 30px;
color: var(--muted);
font-size: 12px;
}
.login-card label,
.user-create-form label {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.login-card label > span,
.user-create-form label > span {
color: #61706b;
font-size: 11px;
font-weight: 650;
}
.login-card input,
.user-create-form input,
.user-create-form select {
height: 48px;
padding: 0 14px;
border: 1px solid #dbe3de;
border-radius: 11px;
outline: none;
background: #fbfcfb;
}
.login-card input:focus,
.user-create-form input:focus,
.user-create-form select:focus {
border-color: #5fb28f;
box-shadow: 0 0 0 3px rgb(50 153 112 / 0.1);
}
.login-card .primary-button {
width: 100%;
height: 50px;
margin-top: 8px;
}
.login-error {
margin: -2px 0 10px;
color: #c45e3a;
font-size: 11px;
}
body { body {
margin: 0; margin: 0;
background: var(--canvas); background: var(--canvas);
@@ -293,6 +422,17 @@ a {
margin-top: 12px; margin-top: 12px;
padding: 10px 8px 0; padding: 10px 8px 0;
border-top: 1px solid rgb(255 255 255 / 0.06); border-top: 1px solid rgb(255 255 255 / 0.06);
border-right: 0;
border-bottom: 0;
border-left: 0;
width: 100%;
color: inherit;
background: transparent;
text-align: left;
}
.sidebar-user:hover {
background: rgb(255 255 255 / 0.035);
} }
.chevron { .chevron {
@@ -1748,6 +1888,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;
} }
@@ -2735,6 +2910,258 @@ label small {
font-size: 9px; font-size: 9px;
} }
.user-management-layout {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
min-width: 0;
}
.user-create-panel,
.user-list-panel {
align-self: start;
min-width: 0;
padding: 22px;
}
.user-create-form {
display: grid;
gap: 12px;
align-items: end;
margin-top: 18px;
}
.user-create-form.with-role {
grid-template-columns: minmax(190px, 1.15fr) minmax(190px, 1fr) minmax(150px, 0.72fr) 132px;
}
.user-create-form.without-role {
grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) 132px;
}
.user-create-form label {
min-width: 0;
margin-bottom: 0;
}
.user-create-form .primary-button {
width: 100%;
height: 40px;
min-height: 40px;
}
.user-management-message {
margin: 14px 0 0;
padding: 10px 12px;
border-radius: 9px;
color: #27634f;
background: #edf7f2;
font-size: 10px;
}
.user-table {
width: 100%;
max-width: 100%;
margin-top: 16px;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.user-table-head,
.user-table-row {
display: grid;
grid-template-columns: minmax(160px, 1.4fr) minmax(100px, 0.7fr) minmax(110px, 0.75fr) minmax(180px, auto);
gap: 12px;
align-items: center;
padding: 14px 16px;
}
.user-table-head {
color: #899490;
background: #f5f7f5;
font-size: 9px;
}
.user-table-row {
border-top: 1px solid var(--line);
font-size: 10px;
}
.user-table-row:hover {
background: #fbfcfa;
}
.user-table-row > span:not(.role-pill) {
color: #84908c;
}
.role-pill {
width: fit-content;
padding: 5px 8px;
border-radius: 999px;
color: #53625d;
background: #edf1ee;
font-size: 8px;
font-weight: 650;
}
.role-pill.super_admin {
color: #7d5a14;
background: #fff4d9;
}
.role-pill.admin {
color: #22614b;
background: #e5f5ed;
}
.user-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.user-action-button {
min-height: 30px;
padding: 0 10px;
border: 1px solid #dce5e1;
border-radius: 8px;
color: var(--green-deep);
background: white;
font-size: 9px;
font-weight: 650;
}
.user-action-button:hover {
border-color: #a8cbbd;
background: #f4faf7;
}
.user-action-button.danger {
border-color: #efd7d3;
color: #a8473b;
}
.user-action-button.danger:hover {
border-color: #dfb5ae;
background: #fff7f5;
}
.danger-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
padding: 0 15px;
border: 1px solid #a83f34;
border-radius: 9px;
color: white;
background: #b94d41;
font-size: 11px;
font-weight: 650;
}
.danger-button:hover {
background: #9f3e34;
}
.danger-button:disabled {
cursor: wait;
opacity: 0.62;
}
.eyebrow.danger {
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;
border: 1px solid #f0dedb;
border-radius: 10px;
color: #7f5049;
background: #fff8f6;
font-size: 10px;
line-height: 1.65;
}
.toast { .toast {
position: fixed; position: fixed;
right: 24px; right: 24px;
@@ -2878,9 +3305,31 @@ label small {
.schedule-grid { .schedule-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.user-create-form.with-role,
.user-create-form.without-role {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-create-form .primary-button {
align-self: end;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
padding: 20px;
}
.login-brand {
min-height: 260px;
}
.user-management-layout {
grid-template-columns: 1fr;
}
.sidebar { .sidebar {
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 180ms ease; transition: transform 180ms ease;
@@ -2957,6 +3406,42 @@ label small {
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.login-page {
padding: 12px;
}
.login-brand {
min-height: 210px;
padding: 30px;
}
.login-brand h1 {
font-size: 40px;
}
.login-card {
padding: 28px 22px;
}
.user-table {
overflow-x: auto;
}
.user-table-head,
.user-table-row {
min-width: 660px;
}
.user-create-panel,
.user-list-panel {
padding: 18px;
}
.user-create-form.with-role,
.user-create-form.without-role {
grid-template-columns: 1fr;
}
.topbar { .topbar {
height: 62px; height: 62px;
} }

69
app/login/page.tsx Normal file
View File

@@ -0,0 +1,69 @@
"use client";
import { FormEvent, useState } from "react";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [working, setWorking] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
setError("");
setWorking(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const result = (await response.json()) as { error?: string };
if (!response.ok) throw new Error(result.error || "登录失败");
window.location.assign("/");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "登录失败");
} finally {
setWorking(false);
}
};
return (
<main className="login-page">
<section className="login-brand">
<div className="login-logo">K</div>
<p>KOC LOOP</p>
<h1></h1>
<span>KOC </span>
</section>
<form className="login-card" onSubmit={submit}>
<p className="eyebrow"></p>
<h2></h2>
<span className="login-tip">使</span>
<label>
<span></span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</label>
<label>
<span></span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<button className="primary-button" disabled={working}>
{working ? "登录中…" : "登录"}
</button>
</form>
</main>
);
}

View File

@@ -1,24 +1,13 @@
import { chatGPTSignOutPath, requireChatGPTUser } from "./chatgpt-auth"; import { headers } from "next/headers";
import { redirect } from "next/navigation";
import AdminApp from "./admin-app"; import AdminApp from "./admin-app";
import { isAdminEmail } from "../lib/admin-auth"; import { getUserFromCookieHeader } from "../lib/user-auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Page() { export default async function Page() {
const user = await requireChatGPTUser("/"); const requestHeaders = await headers();
const user = await getUserFromCookieHeader(requestHeaders.get("cookie"));
if (!isAdminEmail(user.email)) { if (!user) redirect("/login");
return ( return <AdminApp currentUser={user} />;
<main className="admin-access-denied">
<section>
<p>KOC LOOP</p>
<h1></h1>
<span></span>
<a href={chatGPTSignOutPath("/")}></a>
</section>
</main>
);
}
return <AdminApp />;
} }

312
app/users-page.tsx Normal file
View File

@@ -0,0 +1,312 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import type { AuthUser, UserRole } from "../lib/user-auth";
type ManagedUser = {
id: string;
username: string;
role: UserRole;
created_at: string;
updated_at: string;
};
const ROLE_LABEL: Record<UserRole, string> = {
super_admin: "超级管理员",
admin: "管理员",
user: "普通用户",
};
async function userApi(response: Response) {
const result = (await response.json()) as { users?: ManagedUser[]; error?: string };
if (!response.ok) throw new Error(result.error || "操作失败");
return result.users ?? [];
}
export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ManagedUser | null>(null);
const [message, setMessage] = useState("");
const [working, setWorking] = useState(false);
const loadUsers = useCallback(async () => {
try {
setUsers(await userApi(await fetch("/api/users", { cache: "no-store" })));
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "加载失败");
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadUsers();
}, 0);
return () => window.clearTimeout(timer);
}, [loadUsers]);
const createUser = async (event: FormEvent) => {
event.preventDefault();
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "create", username, password, role }),
}),
);
setUsers(next);
setUsername("");
setPassword("");
setRole("user");
setMessage("账号已创建,可以把账号和密码交给用户登录");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "创建失败");
} finally {
setWorking(false);
}
};
const resetUserPassword = async (event: FormEvent) => {
event.preventDefault();
if (!resetTarget) return;
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "reset_password",
userId: resetTarget.id,
password: resetPassword,
}),
}),
);
setUsers(next);
setResetTarget(null);
setResetPassword("");
setMessage("密码已重置,旧登录状态已退出");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "重置失败");
} finally {
setWorking(false);
}
};
const deleteUser = async () => {
if (!deleteTarget) return;
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "delete",
userId: deleteTarget.id,
}),
}),
);
setUsers(next);
setDeleteTarget(null);
setMessage("账号已删除,该账号所有登录设备均已退出");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "删除失败");
} finally {
setWorking(false);
}
};
const canReset = (item: ManagedUser) => {
if (currentUser.role === "super_admin") {
return item.role !== "super_admin" || item.id === currentUser.id;
}
return item.role === "user";
};
const canDelete = (item: ManagedUser) => {
if (item.id === currentUser.id || item.role === "super_admin") return false;
return currentUser.role === "super_admin" || item.role === "user";
};
return (
<div className="user-management-layout">
<section className="panel user-create-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<form
className={`user-create-form ${currentUser.role === "super_admin" ? "with-role" : "without-role"}`}
onSubmit={createUser}
>
<label>
<span></span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="例如linlin01"
autoComplete="off"
required
/>
</label>
<label>
<span></span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少8位"
autoComplete="new-password"
minLength={8}
required
/>
</label>
{currentUser.role === "super_admin" && (
<label className="user-role-field">
<span></span>
<select value={role} onChange={(event) => setRole(event.target.value as "admin" | "user")}>
<option value="user"></option>
<option value="admin"></option>
</select>
</label>
)}
<button className="primary-button" disabled={working}>
{working ? "处理中…" : "创建账号"}
</button>
</form>
{message && <p className="user-management-message">{message}</p>}
</section>
<section className="panel user-list-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p> {users.length} </p>
</div>
</div>
<div className="user-table">
<div className="user-table-head">
<span></span><span></span><span></span><span></span>
</div>
{users.map((item) => (
<div className="user-table-row" key={item.id}>
<strong>{item.username}</strong>
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
<div className="user-row-actions">
{canReset(item) && (
<button
className="user-action-button"
onClick={() => { setResetTarget(item); setResetPassword(""); }}
>
</button>
)}
{canDelete(item) && (
<button
className="user-action-button danger"
onClick={() => setDeleteTarget(item)}
>
</button>
)}
{!canReset(item) && !canDelete(item) && <span></span>}
</div>
</div>
))}
</div>
</section>
{resetTarget && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setResetTarget(null)}>
<form className="modal-card compact" onSubmit={resetUserPassword} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div><p className="eyebrow"></p><h2> {resetTarget.username} </h2></div>
<button type="button" onClick={() => setResetTarget(null)} aria-label="关闭">×</button>
</div>
<label>
<span></span>
<input
type="password"
value={resetPassword}
onChange={(event) => setResetPassword(event.target.value)}
minLength={8}
autoComplete="new-password"
required
/>
<small>退</small>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setResetTarget(null)}></button>
<button className="primary-button" disabled={working}></button>
</div>
</form>
</div>
)}
{deleteTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setDeleteTarget(null); }}
>
<div
className="modal-card compact user-delete-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-user-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow danger"></p>
<h2 id="delete-user-title"> {deleteTarget.username}</h2>
</div>
<button
type="button"
onClick={() => setDeleteTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<p className="user-delete-warning">
退
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setDeleteTarget(null)}
disabled={working}
>
</button>
<button
type="button"
className="danger-button"
onClick={() => void deleteUser()}
disabled={working}
>
{working ? "删除中…" : "确认删除"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -186,3 +186,49 @@ export const collectionRuns = sqliteTable(
), ),
], ],
); );
export const users = sqliteTable(
"users",
{
id: text("id").primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
passwordSalt: text("password_salt").notNull(),
passwordIterations: integer("password_iterations").notNull(),
role: text("role").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("users_username_idx").on(table.username),
uniqueIndex("users_single_super_admin_idx")
.on(table.role)
.where(sql`${table.role} = 'super_admin'`),
],
);
export const authSessions = sqliteTable(
"auth_sessions",
{
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("auth_sessions_user_id_idx").on(table.userId),
index("auth_sessions_expires_at_idx").on(table.expiresAt),
],
);
export const mcpExportTokens = sqliteTable(
"mcp_export_tokens",
{
tokenHash: text("token_hash").primaryKey(),
kind: text("kind").notNull(),
payload: text("payload").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("mcp_export_tokens_expires_at_idx").on(table.expiresAt)],
);

50
design-qa.md Normal file
View File

@@ -0,0 +1,50 @@
# KOC LOOP 用户管理页设计 QA
- Source visual truth: `/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-d06a2293-9e7d-4471-b4ae-5eee942b68e9.png`
- Implementation screenshot: `/private/tmp/koc-user-management-final.jpg`
- Delete confirmation screenshot: `/private/tmp/koc-user-delete-modal.jpg`
- Viewport: desktop `1280 × 720` CSS px; responsive check `680 × 900` CSS px
- Pixels and density: source `2738 × 1382`; implementation `1280 × 720`; browser reported `devicePixelRatio = 2`; comparison used the visible layout and computed CSS geometry rather than pixel-perfect scaling because the requested result intentionally changes the source from side-by-side to stacked sections.
- State: logged-in super administrator on the user-management screen
## Full-view comparison evidence
The source places account creation and the account list side by side, forcing a tall narrow form and compressing the list. The implementation intentionally stacks the sections: the account-creation card spans the page and uses one compact horizontal row on desktop; the account list spans the full row below it. Existing KOC LOOP navigation, typography, colors, border treatment, and panel radius remain unchanged.
## Focused-region comparison evidence
The delete confirmation modal was checked separately. It uses the existing modal shell, a restrained destructive color, explicit irreversible-action copy, cancel and confirm actions, and a disabled working state. No new image assets are present on this screen.
## Required fidelity surfaces
- Fonts and typography: existing product font stack, heading hierarchy, weights, and field labels are preserved; passed.
- Spacing and layout rhythm: panel padding, 16 px vertical section gap, 12 px form gap, and 14 px table rows establish a clearer rhythm; passed after responsive overflow fix.
- Colors and visual tokens: existing green, canvas, line, and panel tokens are preserved; destructive actions use a muted red semantic treatment; passed.
- Image quality and asset fidelity: this screen has no content imagery or custom visual assets; not applicable.
- Copy and content: creation, reset, and deletion copy is concise; deletion clearly states immediate sign-out and irreversibility; passed.
## Interaction verification
- Created a local ordinary test account.
- Opened the row-level delete confirmation.
- Confirmed deletion and verified the row disappeared.
- Verified the current super-administrator row has no delete action.
- Verified browser console errors: none.
- Verified desktop page horizontal overflow: none (`scrollWidth = innerWidth = 1280`).
- Verified 680 px responsive page horizontal overflow: none (`scrollWidth = innerWidth = 680`); the table scrolls inside its own container.
## Comparison history
1. Initial responsive pass found the account table's minimum width expanding the parent grid at 680 px.
2. Added `min-width: 0` to the stacked layout and panels, and constrained the table to its panel.
3. Post-fix evidence: page `scrollWidth` reduced from `714` to `680`, matching the viewport; the table retains an internal `660` px scroll surface.
## Findings
No actionable P0, P1, or P2 issues remain.
## Follow-up polish
No blocking polish items. A future iteration may add search when the account count grows substantially.
final result: passed

View File

@@ -0,0 +1,332 @@
# KOC LOOP 部署指南
KOC LOOP 由两个独立站点组成:
| 站点 | 代码目录 | 作用 | 生产地址 |
| --- | --- | --- | --- |
| 运营后台 | 仓库根目录 | 任务导入、内容分发、KOC 资源、数据回收和自动采集 | [KOC LOOP 后台](https://koc-loop-mvp-wufp.pyeongwu.chatgpt.site) |
| KOC 领取站点 | `koc-portal/` | 外部 KOC 领取笔记、查看内容、回填发布及第 7 天数据 | [KOC 领取站点](https://koc-task-portal-wufp.pyeongwu.chatgpt.site) |
两个站点都部署在 Sites。运营后台绑定 Cloudflare D1 数据库和 R2 文件存储KOC 领取站点不保存业务数据,通过公开的合作方接口访问后台。
## 1. 准备项目
运行环境:
- Node.js `>= 22.13.0`
- npm
- Git
- 可访问 Sites 项目和生产环境变量的账号
下载代码并安装两个站点的依赖:
```bash
git clone ssh://git@gta.gbotai.cn:42001/wufengping/koc-loop.git
cd koc-loop
npm ci
cd koc-portal
npm ci
```
## 2. 配置运营后台
### 2.1 资源绑定
运营后台的 `.openai/hosting.json` 必须保留以下逻辑绑定:
```json
{
"project_id": "以仓库现有配置为准",
"d1": "DB",
"r2": "UPLOADS"
}
```
- `DB`:保存任务、笔记、领取、发布、账号资源和采集记录。
- `UPLOADS`:保存飞书配图、发布截图和创作者中心截图。
- 已有 `project_id` 时必须复用,不能重新创建站点,否则会产生新的数据库、存储和生产地址。
KOC 领取站点使用 `koc-portal/.openai/hosting.json` 中的既有 `project_id`,不绑定 D1 和 R2。
### 2.2 生产环境变量
在运营后台 Sites 项目的运行时环境变量中配置以下内容,不能保留示例占位值:
| 变量 | 类型 | 用途 |
| --- | --- | --- |
| `SUPER_ADMIN_USERNAME` | 密钥 | 首次初始化时创建唯一超级管理员的登录账号 |
| `SUPER_ADMIN_PASSWORD` | 密钥 | 首次初始化时创建唯一超级管理员的初始密码 |
| `ADMIN_INTERNAL_TOKEN` | 密钥 | 定时采集补跑、内部管理接口调用鉴权 |
| `KOC_MCP_API_KEY` | 密钥 | Agent 调用 `/api/mcp` 的独立 Bearer 密钥 |
| `KOC_PORTAL_URL` | 普通变量 | KOC 领取站点生产 Origin用于生成领取链接和 CORS 校验 |
| `AI_TOOL_CENTER_MCP_URL` | 普通变量 | 正式数据采集 MCP 地址 |
| `AI_TOOL_CENTER_MCP_KEY` | 密钥 | 正式数据采集 MCP 密钥 |
| `FEISHU_APP_ID` | 密钥 | 飞书自建应用 App ID |
| `FEISHU_APP_SECRET` | 密钥 | 飞书自建应用 App Secret |
注意:
- 密钥只能保存在本地 `.dev.vars` 或 Sites 运行时环境变量中,禁止写入 Git、部署文档、命令历史或日志。
- `KOC_MCP_API_KEY` 必须和 `ADMIN_INTERNAL_TOKEN``AI_TOOL_CENTER_MCP_KEY` 使用三份不同的随机值,不能复用。
- `SUPER_ADMIN_USERNAME``SUPER_ADMIN_PASSWORD` 只在系统尚无超级管理员时用于初始化。账号创建后,后续账号和密码调整统一在后台“用户管理”中完成。
- `KOC_PORTAL_URL` 应填写领取站点的完整 Origin例如 `https://站点域名`,不要附加任务路径或查询参数。
- 领取站点当前不需要单独配置生产环境变量。它在 `koc-portal/app/page.tsx` 中指向运营后台生产地址;后台地址变化时必须同步修改并重新部署领取站点。
### 2.3 飞书应用权限
飞书自建应用至少需要:
- 读取电子表格;
- 读取知识库节点;
- 下载云文档素材。
同时需要将飞书应用添加到目标知识库或目标电子表格的文档应用中,否则即使 App ID 和 App Secret 正确也无法导入内容。
### 2.4 Agent MCP
生产 MCP 地址固定为:
```text
https://运营后台域名/api/mcp
```
请求使用独立 Bearer 鉴权:
```text
Authorization: Bearer <KOC_MCP_API_KEY>
```
当前应发现 13 个工具:
- 创建任务:`create_distribution_task`
- 任务查询:`task_list``task_get`
- 数据回收:`recovery_list``recovery_export`
- 数据采集:`collection_plan_set``collection_run_due``collection_collect_now``collection_retry_failed`
- KOC 资源:`resource_search``resource_get``resource_backfill_profile``resource_export`
`recovery_export``resource_export` 返回一次性、15 分钟有效的下载链接。链接只承载随机导出令牌,不包含 MCP 密钥;任务导出的原图和截图仍直接嵌入 Excel。
## 3. 本地开发
复制后台环境变量示例:
```bash
cd koc-loop
cp .dev.vars.example .dev.vars
```
`.dev.vars` 中填写本地测试配置。不要提交该文件。
先启动运营后台,使用 `3001` 端口:
```bash
cd koc-loop
npm run dev -- --port 3001
```
再启动 KOC 领取站点,使用 `3000` 端口:
```bash
cd koc-loop/koc-portal
npm run dev -- --port 3000
```
本地领取站点会自动请求 `http://localhost:3001` 的后台接口。
## 4. 发布前验证
运营后台:
```bash
cd koc-loop
npm test
npm run lint
```
KOC 领取站点:
```bash
cd koc-loop/koc-portal
npm test
npm run lint
```
全部命令成功后再提交代码:
```bash
git status
git add <本次修改的文件>
git commit -m "说明本次变更"
git push origin main
```
不要提交 `.dev.vars`、生产密钥、临时压缩包、构建缓存或本地数据库文件。
## 5. 首次部署
KOC LOOP 使用 Sites 版本发布流程,不需要安装 systemd 服务,也不需要自行创建 Cloudflare Worker、D1 或 R2。
### 5.1 部署运营后台
1. 以仓库根目录作为构建目录。
2. 执行 `npm run build`
3. 确认生成:
- `dist/server/index.js`
- `dist/.openai/hosting.json`
- `dist/.openai/drizzle/`
4. 将已经推送到 Git 的同一提交保存为 Sites 版本。
5. 公开部署该版本,并等待部署状态变为成功。
6. 在 Sites 中补齐第 2 节列出的生产环境变量。
7. 将站点访问方式设置为公开后台仍会通过账号密码和角色权限做业务访问控制MCP 则使用独立 Bearer 密钥。
### 5.2 部署 KOC 领取站点
1.`koc-portal/` 作为构建目录。
2. 确认 `PRODUCTION_ADMIN_ORIGIN` 指向已部署的运营后台地址。
3. 执行 `npm run build`
4. 复用 `koc-portal/.openai/hosting.json` 中的既有 Sites 项目。
5. 保存并公开部署新版本。
6. 将领取站点生产地址填入运营后台的 `KOC_PORTAL_URL`
7. 如果 `KOC_PORTAL_URL` 是首次设置或发生变化,重新部署一次运营后台,使新环境变量进入生产版本。
## 6. 更新代码
常规更新流程:
```bash
cd koc-loop
git pull --ff-only
npm ci
npm test
npm run lint
```
根据修改范围决定部署对象:
- 只修改根目录的后台、接口、数据库或采集逻辑:部署运营后台。
- 只修改 `koc-portal/`:部署 KOC 领取站点。
- 同时修改接口和领取页面:先部署运营后台,再部署 KOC 领取站点。
- 修改共享链路、站点地址或 CORS两个站点都要部署并完成联调。
每次部署都必须复用对应 `.openai/hosting.json` 中的 `project_id`,保存新版本后再发布,不能直接用未保存的本地构建覆盖生产环境。
## 7. 数据库变更
修改 `db/schema.ts` 后生成迁移:
```bash
cd koc-loop
npm run db:generate
```
提交前检查:
- `drizzle/` 中只新增预期迁移;
- 不允许手工修改已经在线执行过的旧迁移;
- `npm test` 通过;
- 新字段兼容已有数据和空值。
本版本新增 `mcp_export_tokens` 表,用于保存导出令牌哈希、导出类型、筛选范围和过期时间。数据库只保存令牌哈希,不保存可直接使用的明文令牌;过期记录会在后续签发时清理。
后台构建会把 `drizzle/` 自动复制到 `dist/.openai/drizzle/`Sites 发布时随版本处理数据库迁移。重新部署不会清空 D1 或 R2 数据。
## 8. 每日自动采集
运营后台 Worker 配置了 Cloudflare Cron
```text
0 1 * * *
```
Cloudflare Cron 使用 UTC`01:00 UTC` 对应北京时间每天 `09:00`。定时任务会:
1. 确认数据库结构;
2. 执行当天已创建的笔记数据采集任务;
3. 回写点赞、收藏、评论、总互动和采集状态;
4. 尝试补全账号主页、小红书号、IP 地址和粉丝数。
手动补跑时先将密钥读入当前终端,不要直接写进命令:
```bash
export KOC_ADMIN_URL="https://koc-loop-mvp-wufp.pyeongwu.chatgpt.site"
read -s ADMIN_INTERNAL_TOKEN
export ADMIN_INTERNAL_TOKEN
curl -X POST "$KOC_ADMIN_URL/api/action" \
-H "Content-Type: application/json" \
-H "x-koc-admin-token: $ADMIN_INTERNAL_TOKEN" \
-d '{"action":"run_due_collections"}'
unset ADMIN_INTERNAL_TOKEN
```
接口成功但当天没有符合条件的发布记录时,返回空结果属于正常成功,不应反复重试。
## 9. 部署后验证
### 9.1 运营后台
1. 打开运营后台生产地址。
2. 使用超级管理员账号登录。
3. 确认工作台、内容任务、内容分发、KOC 资源、数据回收和用户管理页面可以打开。
4. 创建一个普通用户账号并登录,确认普通用户无法看到 KOC 资源库;管理员和超级管理员可以正常访问全部业务模块。
5. 使用一个已授权的飞书表格链接执行“读取表格”,确认标题、正文和图片可读取。
6. 检查已有任务和历史截图仍然存在,确认 D1、R2 没有被替换。
### 9.2 KOC 领取站点
1. 从后台复制一个有效任务领取链接。
2. 在未登录后台的浏览器中打开链接。
3. 确认可以领取笔记、查看标题/正文/图片,并回填发布链接与截图。
4. 确认后台能看到对应的领取、发布和数据回收记录。
### 9.3 自动采集
1. 为测试任务设置一个采集日期。
2. 确认对应采集任务已创建。
3. 到点后检查点赞、收藏、评论、总互动、更新时间和采集状态。
4. 异常数据使用后台“一键补采异常数据”或受鉴权的内部接口补跑。
### 9.4 MCP
1. 不带 `Authorization` 请求 `/api/mcp`,确认返回 `401`
2. 使用生产 `KOC_MCP_API_KEY` 执行 `tools/list`,确认发现 13 个工具。
3. 调用 `task_list``resource_search``recovery_list`,确认只读查询正常。
4. 使用测试任务调用 `task_get`,核对笔记、领取、发布回填和采集记录。
5. 调用两种导出工具,确认下载链接在有效期内可打开、过期或二次使用后失效,且 Excel 中图片为直接嵌入。
6. 采集和账号补全工具会访问外部正式采集 MCP只在明确选择测试记录后执行。
## 10. 回滚
优先在 Sites 中选择上一个正常版本重新部署。代码也需要回退时使用:
```bash
git revert <需要撤销的提交>
git push origin main
```
然后按第 6 节重新部署对应站点。不要使用 `git reset --hard` 覆盖共享分支,也不要删除 D1 或 R2 来处理普通代码故障。
## 11. 常见问题
| 现象 | 排查项 |
| --- | --- |
| 超级管理员无法首次登录 | 检查 `SUPER_ADMIN_USERNAME``SUPER_ADMIN_PASSWORD` 是否已配置;若系统已有超级管理员,应在后台重置账号密码 |
| MCP 返回 401 | 检查 Agent 请求头是否使用独立的 `KOC_MCP_API_KEY`,不要误用后台或数据采集密钥 |
| MCP 导出链接失效 | 重新调用导出工具生成新链接;链接为一次性且仅保留 15 分钟 |
| KOC 领取页无法访问后台接口 | 检查 `KOC_PORTAL_URL`、领取站点 Origin、后台地址和 CORS |
| 飞书表格读取失败 | 检查飞书 App ID/Secret、应用权限、文档应用授权和表格链接 |
| 自动采集失败 | 检查正式 MCP URL/Key、笔记发布链接、采集计划和 Worker 日志 |
| 图片上传或查看失败 | 检查运营后台 `UPLOADS` R2 绑定 |
| 构建提示 `vinext: command not found` | 在对应站点目录执行 `npm ci` 后重新构建 |
| 定时任务未执行 | 检查生产版本是否包含 Cron、采集日期是否已保存、笔记是否已回填发布链接 |
## 12. 参考资料
- [MCP 部署指南](https://gta.gbotai.cn/ai-team/mcp-project/src/branch/main/docs/MCP%20%E9%83%A8%E7%BD%B2%E6%8C%87%E5%8D%97.md)
- 仓库根目录 `README.md`
- `koc-portal/README.md`
- `.openai/hosting.json`
- `koc-portal/.openai/hosting.json`

View File

@@ -0,0 +1,22 @@
CREATE TABLE `auth_sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE INDEX `auth_sessions_user_id_idx` ON `auth_sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `auth_sessions_expires_at_idx` ON `auth_sessions` (`expires_at`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`password_salt` text NOT NULL,
`password_iterations` integer NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_idx` ON `users` (`username`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_single_super_admin_idx` ON `users` (`role`) WHERE "users"."role" = 'super_admin';

View File

@@ -0,0 +1,9 @@
CREATE TABLE `mcp_export_tokens` (
`token_hash` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`payload` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE INDEX `mcp_export_tokens_expires_at_idx` ON `mcp_export_tokens` (`expires_at`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,20 @@
"when": 1785380450391, "when": 1785380450391,
"tag": "0006_moaning_dark_phoenix", "tag": "0006_moaning_dark_phoenix",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1786069402457,
"tag": "0007_fantastic_sentinels",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1786082929799,
"tag": "0008_worried_ultimatum",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,35 +1,4 @@
import { env } from "cloudflare:workers"; export {
authForbidden as adminForbidden,
export function isAdminEmail(input: string | null | undefined) { isAppRequest as isAdminRequest,
const allowedEmail = String( } from "./user-auth";
(env as unknown as { ADMIN_ALLOWED_EMAIL?: string }).ADMIN_ALLOWED_EMAIL ??
"",
)
.trim()
.toLowerCase();
const requestEmail = String(input ?? "").trim().toLowerCase();
return Boolean(allowedEmail && requestEmail && requestEmail === allowedEmail);
}
export function isAdminRequest(request: Request) {
if (isAdminEmail(request.headers.get("oai-authenticated-user-email"))) {
return true;
}
const expectedToken = String(
(env as unknown as { ADMIN_INTERNAL_TOKEN?: string }).ADMIN_INTERNAL_TOKEN ??
"",
).trim();
const requestToken = String(
request.headers.get("x-koc-admin-token") ?? "",
).trim();
return Boolean(
expectedToken &&
requestToken &&
expectedToken.length === requestToken.length &&
expectedToken === requestToken,
);
}
export function adminForbidden() {
return Response.json({ error: "无后台操作权限" }, { status: 403 });
}

View 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)
);
}

View File

@@ -3,6 +3,9 @@ import {
type CollectionMcpConfig, type CollectionMcpConfig,
} from "./mcp-collection-client"; } from "./mcp-collection-client";
import { uid } from "./mvp-db"; import { uid } from "./mvp-db";
import { isCollectionScheduleDue } from "./collection-schedule";
export { isCollectionScheduleDue } from "./collection-schedule";
type DistributionForCollection = { type DistributionForCollection = {
id: string; id: string;
@@ -51,21 +54,11 @@ export function shanghaiDateFromTimestamp(timestamp: number) {
.slice(0, 10); .slice(0, 10);
} }
function shanghaiHourFromTimestamp(timestamp: number) {
return Number(
new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(11, 13),
);
}
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,
@@ -77,8 +70,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)),
), ),
); );
} }
@@ -130,8 +122,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自动采集`,
), ),
); );
} }
@@ -161,7 +153,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(
@@ -230,7 +222,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

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

75
lib/mcp-export-token.ts Normal file
View File

@@ -0,0 +1,75 @@
import { ensureSchema, getRawDb } from "./mvp-db";
export type McpExportKind = "recovery" | "resources";
type ExportTokenRow = {
kind: McpExportKind;
payload: string;
expires_at: string;
};
function sqliteTimestamp(date: Date) {
return date.toISOString().replace("T", " ").slice(0, 19);
}
async function digest(value: string) {
const bytes = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return [...new Uint8Array(bytes)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function issueMcpExportToken(
kind: McpExportKind,
payload: Record<string, unknown>,
ttlMinutes = 15,
) {
await ensureSchema();
const db = getRawDb();
const token = `${crypto.randomUUID().replaceAll("-", "")}${crypto
.randomUUID()
.replaceAll("-", "")}`;
const expiresAt = new Date(
Date.now() + Math.max(1, Math.min(60, ttlMinutes)) * 60_000,
);
await db.prepare("DELETE FROM mcp_export_tokens WHERE expires_at <= CURRENT_TIMESTAMP").run();
await db
.prepare(
`INSERT INTO mcp_export_tokens (token_hash, kind, payload, expires_at)
VALUES (?, ?, ?, ?)`,
)
.bind(await digest(token), kind, JSON.stringify(payload), sqliteTimestamp(expiresAt))
.run();
return { token, expiresAt: expiresAt.toISOString() };
}
export async function consumeMcpExportToken(
token: string,
expectedKind: McpExportKind,
) {
if (!token || token.length < 32) return null;
await ensureSchema();
const db = getRawDb();
const tokenHash = await digest(token);
const row = await db
.prepare(
`SELECT kind, payload, expires_at
FROM mcp_export_tokens
WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`,
)
.bind(tokenHash)
.first<ExportTokenRow>();
if (!row || row.kind !== expectedKind) return null;
await db
.prepare("DELETE FROM mcp_export_tokens WHERE token_hash = ?")
.bind(tokenHash)
.run();
try {
return JSON.parse(row.payload) as Record<string, unknown>;
} catch {
return null;
}
}

464
lib/mcp-operations.ts Normal file
View File

@@ -0,0 +1,464 @@
import { enrichDistributionAccount } from "./account-enrichment-service";
import {
collectDistributionMetrics,
createCollectionRunTasks,
retryFailedCollections,
runDueScheduledCollections,
shanghaiDateFromTimestamp,
} from "./collection-service";
import { issueMcpExportToken } from "./mcp-export-token";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "./mcp-collection-client";
import { ensureSchema, getRawDb } from "./mvp-db";
import { extractXhsPublishUrl } from "./publish-url";
import { buildClaimUrl } from "./task-service";
export type McpOperationBindings = CollectionMcpBindings & {
KOC_PORTAL_URL?: string;
};
type Pagination = { limit?: number; offset?: number };
type ResourceFilters = Pagination & {
query?: string;
ipLocation?: string;
cooperationSource?: string;
platform?: string;
};
function page(input: Pagination) {
return {
limit: Math.max(1, Math.min(200, Math.floor(input.limit ?? 50))),
offset: Math.max(0, Math.floor(input.offset ?? 0)),
};
}
function like(value: string) {
return `%${value.replace(/[\\%_]/g, "\\$&")}%`;
}
function parseJsonArray(value: unknown) {
try {
const parsed = JSON.parse(String(value ?? "[]"));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function taskExists(taskId: string) {
const task = await getRawDb()
.prepare("SELECT id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string }>();
if (!task) throw new Error("任务不存在");
}
export async function taskList(
input: Pagination & { query?: string; status?: string },
portalUrl: string,
) {
await ensureSchema();
const { limit, offset } = page(input);
const conditions: string[] = [];
const bindings: unknown[] = [];
if (input.query?.trim()) {
conditions.push("(t.name LIKE ? ESCAPE '\\' OR t.brand LIKE ? ESCAPE '\\')");
const pattern = like(input.query.trim());
bindings.push(pattern, pattern);
}
if (input.status?.trim() && input.status !== "all") {
conditions.push("t.status = ?");
bindings.push(input.status.trim());
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const db = getRawDb();
const [rows, count] = await Promise.all([
db
.prepare(
`SELECT t.*,
(SELECT COUNT(*) FROM contents c WHERE c.task_id = t.id) AS note_count,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS claimed_count,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND COALESCE(d.publish_url, '') != '') AS published_count,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.exposure IS NOT NULL AND d.views IS NOT NULL) AS day7_count
FROM tasks t ${where}
ORDER BY t.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(...bindings, limit, offset)
.all<Record<string, unknown>>(),
db
.prepare(`SELECT COUNT(*) AS total FROM tasks t ${where}`)
.bind(...bindings)
.first<{ total: number }>(),
]);
return {
total: count?.total ?? 0,
limit,
offset,
tasks: rows.results.map((row) => ({
...row,
collection_days: parseJsonArray(row.collection_days),
claim_url:
portalUrl && row.share_token
? buildClaimUrl(portalUrl, String(row.share_token))
: null,
})),
};
}
export async function taskGet(taskId: string, portalUrl: string) {
await ensureSchema();
const db = getRawDb();
const task = await db
.prepare("SELECT * FROM tasks WHERE id = ?")
.bind(taskId)
.first<Record<string, unknown>>();
if (!task) throw new Error("任务不存在");
const [notes, claims, runs] = await Promise.all([
db
.prepare(
`SELECT c.id AS content_id, c.source_row, c.title, c.body, c.image_assets, c.status AS content_status,
d.id AS distribution_id, d.status AS distribution_status, d.publish_url, d.publish_time,
d.exposure, d.views, d.latest_likes, d.latest_comments, d.latest_collects,
d.collection_status, d.collection_status_description, d.collection_updated_at,
a.id AS account_id, a.nickname AS account_nickname, a.profile_url, a.followers,
p.name AS cooperation_source, cl.claimant_name
FROM contents c
LEFT JOIN distributions d ON d.content_id = c.id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE c.task_id = ?
ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`,
)
.bind(taskId)
.all<Record<string, unknown>>(),
db
.prepare(
`SELECT cl.*, p.name AS partner_name,
(SELECT COUNT(*) FROM distributions d WHERE d.claim_id = cl.id AND COALESCE(d.publish_url, '') != '') AS published_count
FROM claims cl JOIN partners p ON p.id = cl.partner_id
WHERE cl.task_id = ? ORDER BY cl.created_at DESC`,
)
.bind(taskId)
.all<Record<string, unknown>>(),
db
.prepare(
`SELECT * FROM collection_runs WHERE task_id = ?
ORDER BY scheduled_date DESC, created_at DESC LIMIT 500`,
)
.bind(taskId)
.all<Record<string, unknown>>(),
]);
return {
task: {
...task,
collection_days: parseJsonArray(task.collection_days),
claim_url:
portalUrl && task.share_token
? buildClaimUrl(portalUrl, String(task.share_token))
: null,
},
notes: notes.results.map((row) => ({
...row,
image_assets: parseJsonArray(row.image_assets),
total_interactions:
row.latest_likes == null
? null
: Number(row.latest_likes) +
Number(row.latest_comments ?? 0) +
Number(row.latest_collects ?? 0),
})),
claims: claims.results,
collection_runs: runs.results,
};
}
export async function recoveryList(
input: Pagination & {
taskId?: string;
stage?: "all" | "published" | "unfilled" | "waiting_day7" | "day7_due";
},
) {
await ensureSchema();
const { limit, offset } = page(input);
const db = getRawDb();
const conditions: string[] = [];
const bindings: unknown[] = [];
if (input.taskId?.trim()) {
conditions.push("d.task_id = ?");
bindings.push(input.taskId.trim());
}
const stage = input.stage ?? "all";
if (stage === "published") conditions.push("COALESCE(d.publish_url, '') != ''");
if (stage === "unfilled") conditions.push("COALESCE(d.publish_url, '') = ''");
if (stage === "waiting_day7") {
conditions.push("COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL)");
}
if (stage === "day7_due") {
conditions.push(
"COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL) AND datetime(d.publish_time, '+7 days') <= CURRENT_TIMESTAMP",
);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const base = `FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN contents c ON c.id = d.content_id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id ${where}`;
const [rows, count] = await Promise.all([
db
.prepare(
`SELECT d.*, t.name AS task_name, c.source_row, c.title,
a.nickname AS account_nickname, a.profile_url, p.name AS cooperation_source,
cl.claimant_name ${base}
ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC LIMIT ? OFFSET ?`,
)
.bind(...bindings, limit, offset)
.all<Record<string, unknown>>(),
db
.prepare(`SELECT COUNT(*) AS total ${base}`)
.bind(...bindings)
.first<{ total: number }>(),
]);
return {
total: count?.total ?? 0,
limit,
offset,
items: rows.results.map((row) => ({
...row,
total_interactions:
row.latest_likes == null
? null
: Number(row.latest_likes) + Number(row.latest_comments ?? 0) + Number(row.latest_collects ?? 0),
})),
};
}
export async function recoveryExport(taskId: string, origin: string) {
await ensureSchema();
await taskExists(taskId);
const issued = await issueMcpExportToken("recovery", { taskId });
return {
task_id: taskId,
download_url: `${origin}/api/recovery-export?token=${encodeURIComponent(issued.token)}`,
expires_at: issued.expiresAt,
};
}
export async function setCollectionPlan(
taskId: string,
startDate: string,
days: number[],
bindings: McpOperationBindings,
) {
await ensureSchema();
await taskExists(taskId);
const normalizedDays = [...new Set(days)]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate) || Number.isNaN(Date.parse(`${startDate}T00:00:00+08:00`))) {
throw new Error("开始采集日期无效");
}
if (!normalizedDays.length) throw new Error("请至少选择一个采集日");
const db = getRawDb();
await db.batch([
db.prepare(
`UPDATE tasks SET collection_start_date = ?, collection_days = ?,
collection_schedule_updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).bind(startDate, JSON.stringify(normalizedDays), taskId),
db.prepare(
`UPDATE distributions SET
collection_status = CASE WHEN latest_likes IS NULL THEN 'scheduled' ELSE collection_status END,
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}个采集日每日09:00执行`, taskId),
]);
const created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
const catchup = await runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(bindings),
"catchup",
taskId,
);
return { task_id: taskId, start_date: startDate, days: normalizedDays, created, catchup };
}
export async function runDueCollections(taskId: string | undefined, bindings: McpOperationBindings) {
await ensureSchema();
if (taskId) await taskExists(taskId);
return runDueScheduledCollections(
getRawDb(),
Date.now(),
resolveCollectionMcpConfig(bindings),
"catchup",
taskId,
);
}
export async function collectNow(
distributionId: string,
scheduleDay: number | undefined,
bindings: McpOperationBindings,
) {
await ensureSchema();
const day = scheduleDay ?? null;
const metrics = await collectDistributionMetrics(
getRawDb(),
distributionId,
shanghaiDateFromTimestamp(Date.now()),
day,
"manual",
resolveCollectionMcpConfig(bindings),
);
return { distribution_id: distributionId, schedule_day: day, ...metrics };
}
export async function retryFailed(taskId: string | undefined, bindings: McpOperationBindings) {
await ensureSchema();
const db = getRawDb();
const ids = taskId
? [taskId]
: (
await db
.prepare(
`SELECT DISTINCT task_id FROM distributions
WHERE collection_status = 'failed' ORDER BY updated_at DESC LIMIT 20`,
)
.all<{ task_id: string }>()
).results.map((row) => row.task_id);
if (taskId) await taskExists(taskId);
const results = [];
for (const id of ids) {
results.push({ task_id: id, ...(await retryFailedCollections(db, id, resolveCollectionMcpConfig(bindings))) });
}
return {
task_count: results.length,
attempted: results.reduce((sum, item) => sum + item.attempted, 0),
succeeded: results.reduce((sum, item) => sum + item.succeeded, 0),
failed: results.reduce((sum, item) => sum + item.failed, 0),
tasks: results,
};
}
function resourceWhere(input: ResourceFilters) {
const conditions: string[] = [];
const bindings: unknown[] = [];
if (input.query?.trim()) {
conditions.push("(a.nickname LIKE ? ESCAPE '\\' OR a.public_account_id LIKE ? ESCAPE '\\')");
const pattern = like(input.query.trim());
bindings.push(pattern, pattern);
}
if (input.ipLocation?.trim()) {
conditions.push("a.ip_location LIKE ? ESCAPE '\\'");
bindings.push(like(input.ipLocation.trim()));
}
if (input.cooperationSource?.trim()) {
conditions.push(
`EXISTS (SELECT 1 FROM distributions dx JOIN partners px ON px.id = dx.partner_id
WHERE dx.account_id = a.id AND px.name LIKE ? ESCAPE '\\')`,
);
bindings.push(like(input.cooperationSource.trim()));
}
if (input.platform?.trim() && input.platform !== "all") {
conditions.push("a.platform = ?");
bindings.push(input.platform.trim());
}
return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", bindings };
}
export async function resourceSearch(input: ResourceFilters) {
await ensureSchema();
const { limit, offset } = page(input);
const { where, bindings } = resourceWhere(input);
const db = getRawDb();
const select = `SELECT a.*,
(SELECT COUNT(*) FROM distributions d WHERE d.account_id = a.id) AS cooperation_count,
(SELECT GROUP_CONCAT(DISTINCT p.name) FROM distributions d JOIN partners p ON p.id = d.partner_id WHERE d.account_id = a.id) AS cooperation_sources`;
const [rows, count] = await Promise.all([
db.prepare(`${select} FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT ? OFFSET ?`)
.bind(...bindings, limit, offset).all<Record<string, unknown>>(),
db.prepare(`SELECT COUNT(*) AS total FROM accounts a ${where}`).bind(...bindings).first<{ total: number }>(),
]);
return {
total: count?.total ?? 0,
limit,
offset,
accounts: rows.results.map((row) => ({
...row,
cooperation_sources: String(row.cooperation_sources ?? "").split(",").filter(Boolean),
})),
};
}
export async function resourceGet(accountId: string) {
await ensureSchema();
const db = getRawDb();
const account = await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(accountId).first<Record<string, unknown>>();
if (!account) throw new Error("账号不存在");
const history = await db.prepare(
`SELECT d.id AS distribution_id, d.task_id, t.name AS task_name, c.title,
d.publish_url, d.publish_time, d.latest_likes, d.latest_comments, d.latest_collects,
d.exposure, d.views, p.name AS cooperation_source, cl.claimant_name
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
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE d.account_id = ? ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC`,
).bind(accountId).all<Record<string, unknown>>();
return { account, cooperation_history: history.results };
}
export async function backfillResourceProfile(
input: { distributionId?: string; publishUrl?: string },
bindings: McpOperationBindings,
) {
await ensureSchema();
const db = getRawDb();
const publishUrl = input.publishUrl ? extractXhsPublishUrl(input.publishUrl) : "";
const row = input.distributionId
? await db.prepare(
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
FROM distributions d LEFT JOIN accounts a ON a.id = d.account_id WHERE d.id = ?`,
).bind(input.distributionId).first<{ id: string; publish_url: string | null; nickname: string }>()
: publishUrl
? await db.prepare(
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
FROM distributions d LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.publish_url = ? ORDER BY d.updated_at DESC LIMIT 1`,
).bind(publishUrl).first<{ id: string; publish_url: string | null; nickname: string }>()
: null;
if (!row?.publish_url) throw new Error("没有找到可补全的发布记录");
const result = await enrichDistributionAccount(
db,
row.id,
row.publish_url,
row.nickname,
resolveCollectionMcpConfig(bindings),
);
const account = result.updated && result.accountId
? await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(result.accountId).first<Record<string, unknown>>()
: null;
return { distribution_id: row.id, ...result, account };
}
export async function resourceExport(input: ResourceFilters, origin: string) {
const result = await resourceSearch({ ...input, limit: 200, offset: 0 });
const allIds: string[] = result.accounts.map((account) => String(account.id));
if (result.total > result.accounts.length) {
const { where, bindings } = resourceWhere(input);
const rows = await getRawDb().prepare(`SELECT a.id FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT 5000`)
.bind(...bindings).all<{ id: string }>();
allIds.splice(0, allIds.length, ...rows.results.map((row) => row.id));
}
if (!allIds.length) throw new Error("当前筛选结果为空");
const issued = await issueMcpExportToken("resources", { accountIds: allIds });
return {
account_count: allIds.length,
download_url: `${origin}/api/resources-export?token=${encodeURIComponent(issued.token)}`,
expires_at: issued.expiresAt,
};
}

247
lib/mcp-tools.ts Normal file
View File

@@ -0,0 +1,247 @@
import { type McpServer } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import {
backfillResourceProfile,
collectNow,
recoveryExport,
recoveryList,
resourceExport,
resourceGet,
resourceSearch,
retryFailed,
runDueCollections,
setCollectionPlan,
taskGet,
taskList,
type McpOperationBindings,
} from "./mcp-operations";
type Options = {
bindings: McpOperationBindings;
origin: string;
};
function result(label: string, data: unknown) {
return {
content: [{ type: "text" as const, text: label }],
structuredContent: data as Record<string, unknown>,
};
}
function errorResult(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败,请稍后重试";
return {
isError: true as const,
content: [{ type: "text" as const, text: message.slice(0, 240) }],
};
}
function withError<T extends unknown[]>(handler: (...args: T) => Promise<ReturnType<typeof result>>) {
return async (...args: T) => {
try {
return await handler(...args);
} catch (error) {
return errorResult(error);
}
};
}
const pagination = {
limit: z.number().int().min(1).max(200).optional().describe("返回数量默认50最大200"),
offset: z.number().int().min(0).optional().describe("分页偏移量默认0"),
};
const resourceFilters = {
query: z.string().max(100).optional().describe("账号名称或小红书号/抖音号,支持模糊搜索"),
ip_location: z.string().max(100).optional().describe("IP地区关键词支持模糊搜索"),
cooperation_source: z.string().max(100).optional().describe("历史合作来源关键词,支持模糊搜索"),
platform: z.string().max(30).optional().describe("平台,例如小红书;不传表示全部"),
};
export function registerMcpOperationTools(server: McpServer, options: Options) {
server.registerTool(
"task_list",
{
title: "查询任务及进度",
description: "查询 KOC 分发任务列表、领取数、发布数、第7天回收数及领取链接。",
inputSchema: z.object({
query: z.string().max(100).optional().describe("任务名或品牌/项目关键词"),
status: z.string().max(30).optional().describe("任务状态;不传或 all 表示全部"),
...pagination,
}),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async (input) => {
const data = await taskList(input, options.bindings.KOC_PORTAL_URL?.trim() || "");
return result(`共找到 ${data.total} 个任务。`, data);
}),
);
server.registerTool(
"task_get",
{
title: "查看任务完整情况",
description: "查看指定任务、笔记、领取记录、发布回填和采集执行记录。",
inputSchema: z.object({ task_id: z.string().min(1).describe("任务ID") }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ task_id }) => {
const data = await taskGet(task_id, options.bindings.KOC_PORTAL_URL?.trim() || "");
return result(`已读取任务“${String(data.task.name)}”的完整情况。`, data);
}),
);
server.registerTool(
"recovery_list",
{
title: "查询数据回收队列",
description: "查询已发布、未回填、待第7天数据或已到第7天仍未回填的笔记。",
inputSchema: z.object({
task_id: z.string().optional().describe("任务ID不传则跨任务查询"),
stage: z.enum(["all", "published", "unfilled", "waiting_day7", "day7_due"]).optional(),
...pagination,
}),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ task_id, stage, limit, offset }) => {
const data = await recoveryList({ taskId: task_id, stage, limit, offset });
return result(`数据回收队列共 ${data.total} 条记录。`, data);
}),
);
server.registerTool(
"recovery_export",
{
title: "导出任务完整数据",
description: "生成任务完整 Excel包含笔记原图、发布截图、创作者截图和回收数据。",
inputSchema: z.object({ task_id: z.string().min(1).describe("任务ID") }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
},
withError(async ({ task_id }) => {
const data = await recoveryExport(task_id, options.origin);
return result(`导出文件已生成,下载链接将在 ${data.expires_at} 失效。`, data);
}),
);
server.registerTool(
"collection_plan_set",
{
title: "设置自动采集计划",
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"),
days: z.array(z.number().int().min(1).max(7)).min(1).max(7).describe("需要采集的相对天数,例如 [2,5,7]"),
}),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ task_id, start_date, days }) => {
const data = await setCollectionPlan(task_id, start_date, days, options.bindings);
return result(`已为任务设置 ${data.days.length} 个自动采集日。`, data);
}),
);
server.registerTool(
"collection_run_due",
{
title: "执行到期采集",
description: "立即执行今天或此前已到期但尚未成功的自动采集任务。",
inputSchema: z.object({ task_id: z.string().optional().describe("任务ID不传则执行所有到期任务") }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ task_id }) => {
const data = await runDueCollections(task_id, options.bindings);
return result(`到期采集完成:成功 ${data.succeeded},失败 ${data.failed}`, data);
}),
);
server.registerTool(
"collection_collect_now",
{
title: "立即采集指定笔记",
description: "对指定分发记录立即采集点赞、收藏和评论数据。",
inputSchema: z.object({
distribution_id: z.string().min(1).describe("分发记录ID可从 task_get 或 recovery_list 获取"),
schedule_day: z.number().int().min(1).max(7).optional().describe("标记为第几天采集,可不传"),
}),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ distribution_id, schedule_day }) => {
const data = await collectNow(distribution_id, schedule_day, options.bindings);
return result("指定笔记采集完成。", data);
}),
);
server.registerTool(
"collection_retry_failed",
{
title: "补采异常数据",
description: "重试指定任务或全部任务中采集状态异常的已发布笔记。",
inputSchema: z.object({ task_id: z.string().optional().describe("任务ID不传则补采最近异常任务") }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ task_id }) => {
const data = await retryFailed(task_id, options.bindings);
return result(`补采完成:成功 ${data.succeeded},失败 ${data.failed}`, data);
}),
);
server.registerTool(
"resource_search",
{
title: "搜索 KOC 账号资源",
description: "按账号名称/账号号、IP地区、合作来源或平台搜索 KOC 资源。",
inputSchema: z.object({ ...resourceFilters, ...pagination }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ query, ip_location, cooperation_source, platform, limit, offset }) => {
const data = await resourceSearch({ query, ipLocation: ip_location, cooperationSource: cooperation_source, platform, limit, offset });
return result(`共找到 ${data.total} 个账号。`, data);
}),
);
server.registerTool(
"resource_get",
{
title: "查看 KOC 账号详情",
description: "查看账号主页、账号号、粉丝数、IP地区以及全部合作记录。",
inputSchema: z.object({ account_id: z.string().min(1).describe("KOC LOOP 账号ID") }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ account_id }) => {
const data = await resourceGet(account_id);
return result(`已读取账号“${String(data.account.nickname)}”的详情。`, data);
}),
);
server.registerTool(
"resource_backfill_profile",
{
title: "补全公开账号信息",
description: "根据已回填的发布链接补全账号主页、昵称、小红书号、IP地区和粉丝数。",
inputSchema: z.object({
distribution_id: z.string().optional().describe("分发记录ID和发布链接二选一"),
publish_url: z.string().optional().describe("小红书发布链接或包含链接的分享文案"),
}).refine((value) => Boolean(value.distribution_id || value.publish_url), "请提供分发记录ID或发布链接"),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ distribution_id, publish_url }) => {
const data = await backfillResourceProfile({ distributionId: distribution_id, publishUrl: publish_url }, options.bindings);
return result(data.updated ? "账号公开信息已补全。" : "账号信息未发生变化。", data);
}),
);
server.registerTool(
"resource_export",
{
title: "导出 KOC 资源",
description: "按账号、IP地区、合作来源或平台筛选并导出 KOC 资源 Excel。",
inputSchema: z.object(resourceFilters),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
},
withError(async ({ query, ip_location, cooperation_source, platform }) => {
const data = await resourceExport({ query, ipLocation: ip_location, cooperationSource: cooperation_source, platform }, options.origin);
return result(`已生成 ${data.account_count} 个账号的导出文件。`, data);
}),
);
}

View File

@@ -149,6 +149,29 @@ export async function ensureSchema(database?: D1Database) {
completed_at TEXT, completed_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
password_iterations INTEGER NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS mcp_export_tokens (
token_hash TEXT PRIMARY KEY,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
]; ];
for (const statement of statements) { for (const statement of statements) {
@@ -305,6 +328,30 @@ export async function ensureSchema(database?: D1Database) {
ON collection_runs(task_id, scheduled_date)`, ON collection_runs(task_id, scheduled_date)`,
) )
.run(); .run();
await db
.prepare("CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON users(username)")
.run();
await db
.prepare(
`CREATE UNIQUE INDEX IF NOT EXISTS users_single_super_admin_idx
ON users(role) WHERE role = 'super_admin'`,
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)",
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS auth_sessions_expires_at_idx ON auth_sessions(expires_at)",
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS mcp_export_tokens_expires_at_idx ON mcp_export_tokens(expires_at)",
)
.run();
await db await db
.prepare( .prepare(
`UPDATE distributions `UPDATE distributions

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);
}

222
lib/task-service.ts Normal file
View File

@@ -0,0 +1,222 @@
import { ensureSchema, getRawDb, uid } from "./mvp-db";
import {
readFeishuSource,
type FeishuBindings,
type FeishuSource,
} from "./feishu-client";
export type CreateDistributionTaskInput = {
feishuUrl: string;
name: string;
brand: string;
dueAt: string;
};
export type DistributionTaskCreation = {
created: boolean;
taskId: string;
shareToken: string;
name: string;
brand: string;
dueAt: string;
noteCount: number;
sheetId: string;
sheetName: string;
sourceUrl: string;
};
type TaskRow = {
id: string;
share_token: string | null;
name: string;
brand: string;
due_at: string;
quantity: number;
source_url: string;
source_sheet_id: string;
source_sheet_name: string;
};
function normalizedValue(value: string) {
return String(value ?? "").trim();
}
function normalizedDueDate(value: string) {
const dueAt = normalizedValue(value);
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueAt)) {
throw new Error("截止日期必须使用 YYYY-MM-DD 格式");
}
const [year, month, day] = dueAt.split("-").map(Number);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (
Number.isNaN(parsed.getTime()) ||
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() + 1 !== month ||
parsed.getUTCDate() !== day
) {
throw new Error("截止日期无效");
}
return dueAt;
}
function normalizedFeishuUrl(value: string) {
const input = normalizedValue(value);
try {
const url = new URL(input);
url.searchParams.delete("from");
url.searchParams.sort();
return url.toString();
} catch {
return input;
}
}
async function findExistingTask(
sourceUrl: string,
input: CreateDistributionTaskInput,
) {
const db = getRawDb();
return db
.prepare(
`SELECT id, share_token, name, brand, due_at, quantity,
source_url, source_sheet_id, source_sheet_name
FROM tasks
WHERE name = ?
AND brand = ?
AND due_at = ?
AND source_url = ?
AND status IN ('active', 'importing')
ORDER BY created_at DESC
LIMIT 1`,
)
.bind(input.name, input.brand, input.dueAt, sourceUrl)
.first<TaskRow>();
}
async function insertTaskFromSource(
source: FeishuSource,
input: CreateDistributionTaskInput,
) {
const db = getRawDb();
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
await db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
input.name,
input.brand,
source.rows.length,
input.dueAt,
source.url,
source.sheetId,
source.sheetName,
source.syncedAt,
shareToken,
)
.run();
try {
const contentStatements = source.rows.map((row) => {
const contentId = uid("content");
const imageAssets = row.images.map((image) => ({
...image,
key: `content-assets/${taskId}/${contentId}/${image.index}`,
}));
return db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
)
.bind(
contentId,
taskId,
row.title,
row.body,
JSON.stringify(imageAssets),
`飞书 · ${source.sheetName}`,
row.sourceRow,
);
});
for (let index = 0; index < contentStatements.length; index += 100) {
await db.batch(contentStatements.slice(index, index + 100));
}
await db
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
.bind(taskId)
.run();
} catch (error) {
await db.batch([
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
]);
throw error;
}
return { taskId, shareToken };
}
export async function createDistributionTask(
rawInput: CreateDistributionTaskInput,
bindings: FeishuBindings,
options: { deduplicate?: boolean } = {},
): Promise<DistributionTaskCreation> {
await ensureSchema();
const input = {
feishuUrl: normalizedFeishuUrl(rawInput.feishuUrl),
name: normalizedValue(rawInput.name),
brand: normalizedValue(rawInput.brand),
dueAt: normalizedDueDate(rawInput.dueAt),
};
if (!input.feishuUrl || !input.name || !input.brand) {
throw new Error("请补全飞书链接、任务名称和品牌/项目");
}
if (options.deduplicate) {
const existing = await findExistingTask(input.feishuUrl, input);
if (existing?.share_token) {
return {
created: false,
taskId: existing.id,
shareToken: existing.share_token,
name: existing.name,
brand: existing.brand,
dueAt: existing.due_at,
noteCount: Number(existing.quantity),
sheetId: existing.source_sheet_id,
sheetName: existing.source_sheet_name,
sourceUrl: existing.source_url,
};
}
}
const source = await readFeishuSource(input.feishuUrl, bindings);
const inserted = await insertTaskFromSource(source, input);
return {
created: true,
taskId: inserted.taskId,
shareToken: inserted.shareToken,
name: input.name,
brand: input.brand,
dueAt: input.dueAt,
noteCount: source.rows.length,
sheetId: source.sheetId,
sheetName: source.sheetName,
sourceUrl: source.url,
};
}
export function buildClaimUrl(portalOrigin: string, shareToken: string) {
const origin = normalizedValue(portalOrigin).replace(/\/$/, "");
if (!origin) throw new Error("KOC 领取站点地址尚未配置");
const url = new URL(origin);
url.searchParams.set("task", shareToken);
return url.toString();
}

286
lib/user-auth.ts Normal file
View File

@@ -0,0 +1,286 @@
import { env } from "cloudflare:workers";
import { ensureSchema, getRawDb } from "./mvp-db";
export type UserRole = "super_admin" | "admin" | "user";
export type AuthUser = {
id: string;
username: string;
role: UserRole;
};
export type RequestPrincipal =
| { kind: "internal" }
| { kind: "user"; user: AuthUser };
const SESSION_COOKIE = "koc_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
// Cloudflare Workers caps PBKDF2 at 100,000 iterations.
const PASSWORD_ITERATIONS = 100_000;
function bytesToHex(bytes: Uint8Array) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function hexToBytes(value: string) {
if (!/^(?:[0-9a-f]{2})+$/i.test(value)) return new Uint8Array();
return new Uint8Array(value.match(/.{2}/g)?.map((part) => Number.parseInt(part, 16)) ?? []);
}
function utf8(value: string) {
return new TextEncoder().encode(value);
}
async function sha256(value: string) {
return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(value))));
}
async function derivePasswordHash(
password: string,
saltHex: string,
iterations: number,
) {
const key = await crypto.subtle.importKey(
"raw",
utf8(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
salt: hexToBytes(saltHex),
iterations,
},
key,
256,
);
return bytesToHex(new Uint8Array(bits));
}
function safeEqual(left: string, right: string) {
if (left.length !== right.length) return false;
let mismatch = 0;
for (let index = 0; index < left.length; index += 1) {
mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return mismatch === 0;
}
export function normalizeUsername(value: unknown) {
return String(value ?? "").normalize("NFKC").trim().toLowerCase();
}
export function validateUsername(value: string) {
return /^[\p{L}\p{N}_.@+-]{2,32}$/u.test(value);
}
export function validatePassword(value: string) {
return value.length >= 8 && value.length <= 72;
}
export async function createPasswordRecord(password: string) {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
const passwordSalt = bytesToHex(salt);
return {
passwordHash: await derivePasswordHash(
password,
passwordSalt,
PASSWORD_ITERATIONS,
),
passwordSalt,
passwordIterations: PASSWORD_ITERATIONS,
};
}
export async function verifyPassword(
password: string,
record: {
password_hash: string;
password_salt: string;
password_iterations: number;
},
) {
const candidate = await derivePasswordHash(
password,
record.password_salt,
record.password_iterations,
);
return safeEqual(candidate, record.password_hash);
}
function getAuthEnv() {
return env as unknown as {
SUPER_ADMIN_USERNAME?: string;
SUPER_ADMIN_PASSWORD?: string;
ADMIN_INTERNAL_TOKEN?: string;
};
}
export async function ensureInitialSuperAdmin() {
await ensureSchema();
const db = getRawDb();
const existing = await db
.prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
.first<{ id: string }>();
if (existing) return;
const username = normalizeUsername(getAuthEnv().SUPER_ADMIN_USERNAME);
const password = String(getAuthEnv().SUPER_ADMIN_PASSWORD ?? "");
if (!validateUsername(username) || !validatePassword(password)) {
throw new Error("超级管理员账号尚未配置,请设置 SUPER_ADMIN_USERNAME 和 SUPER_ADMIN_PASSWORD");
}
const conflict = await db
.prepare("SELECT id FROM users WHERE username = ?")
.bind(username)
.first<{ id: string }>();
if (conflict) {
throw new Error("超级管理员用户名已被占用,请更换 SUPER_ADMIN_USERNAME");
}
const passwordRecord = await createPasswordRecord(password);
try {
await db
.prepare(
`INSERT INTO users
(id, username, password_hash, password_salt, password_iterations, role)
VALUES (?, ?, ?, ?, ?, 'super_admin')`,
)
.bind(
crypto.randomUUID(),
username,
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
)
.run();
} catch {
const concurrent = await db
.prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
.first<{ id: string }>();
if (!concurrent) throw new Error("超级管理员初始化失败");
}
}
export function sessionCookieFromHeader(cookieHeader: string | null) {
const match = String(cookieHeader ?? "")
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`));
return match ? decodeURIComponent(match.slice(SESSION_COOKIE.length + 1)) : "";
}
export function createSessionCookie(token: string, secure = true) {
return [
`${SESSION_COOKIE}=${encodeURIComponent(token)}`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
secure ? "Secure" : "",
`Max-Age=${SESSION_MAX_AGE_SECONDS}`,
]
.filter(Boolean)
.join("; ");
}
export function clearSessionCookie(secure = true) {
return [
`${SESSION_COOKIE}=`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
secure ? "Secure" : "",
"Max-Age=0",
]
.filter(Boolean)
.join("; ");
}
export async function createSession(userId: string) {
const tokenBytes = new Uint8Array(32);
crypto.getRandomValues(tokenBytes);
const token = bytesToHex(tokenBytes);
const tokenHash = await sha256(token);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString();
const db = getRawDb();
await db.batch([
db
.prepare("DELETE FROM auth_sessions WHERE expires_at <= ?")
.bind(new Date().toISOString()),
db
.prepare(
`INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES (?, ?, ?)`,
)
.bind(tokenHash, userId, expiresAt),
]);
return token;
}
export async function deleteSession(token: string) {
if (!token) return;
await ensureSchema();
await getRawDb()
.prepare("DELETE FROM auth_sessions WHERE token_hash = ?")
.bind(await sha256(token))
.run();
}
export async function getUserFromSessionToken(token: string): Promise<AuthUser | null> {
if (!token) return null;
await ensureSchema();
const row = await getRawDb()
.prepare(
`SELECT u.id, u.username, u.role
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?
LIMIT 1`,
)
.bind(await sha256(token), new Date().toISOString())
.first<{ id: string; username: string; role: string }>();
if (!row || !["super_admin", "admin", "user"].includes(row.role)) return null;
return { id: row.id, username: row.username, role: row.role as UserRole };
}
export async function getUserFromCookieHeader(cookieHeader: string | null) {
return getUserFromSessionToken(sessionCookieFromHeader(cookieHeader));
}
function hasInternalToken(request: Request) {
const expected = String(getAuthEnv().ADMIN_INTERNAL_TOKEN ?? "").trim();
const received = String(request.headers.get("x-koc-admin-token") ?? "").trim();
return Boolean(expected && received && safeEqual(expected, received));
}
export async function getRequestPrincipal(
request: Request,
): Promise<RequestPrincipal | null> {
if (hasInternalToken(request)) return { kind: "internal" };
const user = await getUserFromCookieHeader(request.headers.get("cookie"));
return user ? { kind: "user", user } : null;
}
export async function isAppRequest(request: Request) {
return Boolean(await getRequestPrincipal(request));
}
export async function isManagerRequest(request: Request) {
const principal = await getRequestPrincipal(request);
return Boolean(
principal &&
(principal.kind === "internal" ||
principal.user.role === "super_admin" ||
principal.user.role === "admin"),
);
}
export function authForbidden() {
return Response.json({ error: "请先登录后再操作" }, { status: 401 });
}
export function managerForbidden() {
return Response.json({ error: "当前账号没有管理权限" }, { status: 403 });
}

30
package-lock.json generated
View File

@@ -8,11 +8,13 @@
"name": "site-creator-vinext-starter", "name": "site-creator-vinext-starter",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/server": "^2.0.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"fflate": "0.7.4", "fflate": "0.7.4",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6" "react-dom": "19.2.6",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "1.37.1", "@cloudflare/vite-plugin": "1.37.1",
@@ -2123,6 +2125,31 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@modelcontextprotocol/core": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz",
"integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",
"license": "MIT",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
"integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -11146,7 +11173,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@@ -14,11 +14,13 @@
"db:generate": "drizzle-kit generate" "db:generate": "drizzle-kit generate"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/server": "^2.0.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"fflate": "0.7.4", "fflate": "0.7.4",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6" "react-dom": "19.2.6",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "1.37.1", "@cloudflare/vite-plugin": "1.37.1",

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

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

View File

@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("exposes authenticated KOC task, recovery, collection, and resource MCP tools", async () => {
const [route, tools, operations, tokenService, migration, taskService, actionRoute, readme, envExample, packageJson] =
await Promise.all([
readFile(new URL("../app/api/mcp/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-tools.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-operations.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-export-token.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0008_worried_ultimatum.sql", import.meta.url), "utf8"),
readFile(new URL("../lib/task-service.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
readFile(new URL("../README.md", import.meta.url), "utf8"),
readFile(new URL("../.dev.vars.example", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
]);
assert.match(route, /createMcpHandler/);
assert.match(route, /create_distribution_task/);
for (const name of [
"task_list",
"task_get",
"recovery_list",
"recovery_export",
"collection_plan_set",
"collection_run_due",
"collection_collect_now",
"collection_retry_failed",
"resource_search",
"resource_get",
"resource_backfill_profile",
"resource_export",
]) {
assert.match(tools, new RegExp(`"${name}"`));
}
assert.match(route, /KOC_MCP_API_KEY/);
assert.match(route, /Authorization/);
assert.match(route, /Bearer/);
assert.match(route, /idempotentHint: true/);
assert.match(route, /brand_project\?\.trim\(\) \|\| "未设置项目"/);
assert.match(route, /buildClaimUrl/);
assert.match(route, /structuredContent: output/);
assert.match(route, /legacy: "stateless"/);
assert.match(route, /responseMode: "json"/);
assert.doesNotMatch(route, /ADMIN_INTERNAL_TOKEN/);
assert.match(route, /registerMcpOperationTools/);
assert.match(operations, /runDueScheduledCollections/);
assert.match(operations, /retryFailedCollections/);
assert.match(operations, /enrichDistributionAccount/);
assert.match(operations, /issueMcpExportToken/);
assert.match(tokenService, /SHA-256/);
assert.match(tokenService, /expires_at > CURRENT_TIMESTAMP/);
assert.doesNotMatch(tokenService, /KOC_MCP_API_KEY/);
assert.match(migration, /CREATE TABLE `mcp_export_tokens`/);
assert.match(migration, /mcp_export_tokens_expires_at_idx/);
assert.match(taskService, /readFeishuSource/);
assert.match(taskService, /options\.deduplicate/);
assert.ok(
taskService.indexOf("findExistingTask(input.feishuUrl, input)") <
taskService.indexOf("readFeishuSource(input.feishuUrl, bindings)"),
);
assert.match(taskService, /searchParams\.delete\("from"\)/);
assert.match(taskService, /status IN \('active', 'importing'\)/);
assert.match(taskService, /shareToken/);
assert.match(actionRoute, /createDistributionTask/);
assert.doesNotMatch(actionRoute, /function createTaskFromSource/);
assert.match(readme, /create_distribution_task/);
assert.match(readme, /collection_plan_set/);
assert.match(readme, /resource_backfill_profile/);
assert.match(readme, /\/api\/mcp/);
assert.match(envExample, /KOC_MCP_API_KEY/);
assert.match(packageJson, /@modelcontextprotocol\/server/);
assert.match(packageJson, /"zod"/);
});

View File

@@ -8,8 +8,9 @@ test("builds the KOC LOOP product shell", async () => {
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.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"),
]); ]);
assert.match(page, /requireChatGPTUser/); assert.match(page, /getUserFromCookieHeader/);
assert.match(page, /isAdminEmail/); assert.match(page, /redirect\("\/login"\)/);
assert.match(page, /currentUser=\{user\}/);
assert.match(adminApp, /KOC LOOP/); assert.match(adminApp, /KOC LOOP/);
assert.match(adminApp, /内容分发闭环/); assert.match(adminApp, /内容分发闭环/);
assert.match(adminApp, /分发工作台/); assert.match(adminApp, /分发工作台/);
@@ -22,6 +23,23 @@ test("builds the KOC LOOP product shell", async () => {
await access(new URL("../dist/client/assets", import.meta.url)); await access(new URL("../dist/client/assets", import.meta.url));
}); });
test("stacks user management and securely removes departed accounts", async () => {
const [usersPage, usersRoute, globalCss] = await Promise.all([
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/users/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(globalCss, /\.user-management-layout\s*\{[^}]*grid-template-columns:\s*1fr/s);
assert.match(usersPage, /删除账号/);
assert.match(usersPage, /role="alertdialog"/);
assert.match(usersRoute, /body\.action === "delete"/);
assert.match(usersRoute, /不能删除当前登录账号/);
assert.match(usersRoute, /不能删除超级管理员账号/);
assert.match(usersRoute, /DELETE FROM auth_sessions WHERE user_id = \?/);
assert.match(usersRoute, /DELETE FROM users WHERE id = \? AND role <> 'super_admin'/);
});
test("ships persistence, uploads, metadata, and no starter preview", async () => { test("ships persistence, uploads, metadata, and no starter preview", async () => {
const [adminApp, layout, packageJson, hosting] = await Promise.all([ const [adminApp, layout, packageJson, hosting] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
@@ -196,6 +214,10 @@ 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, /内容 \/ 发布账号/); assert.match(adminApp, /内容 \/ 发布账号/);
assert.match(adminApp, /recovery-title-link/); assert.match(adminApp, /recovery-title-link/);
assert.match(adminApp, /打开小红书笔记/); assert.match(adminApp, /打开小红书笔记/);
@@ -215,17 +237,18 @@ 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(worker, /async scheduled/); assert.match(worker, /async scheduled/);
assert.match(worker, /backfillAccountProfiles/); assert.match(worker, /backfillAccountProfiles/);
assert.match(bootstrapRoute, /backfillAccountProfiles/); assert.match(bootstrapRoute, /backfillAccountProfiles/);
assert.match(viteConfig, /"0 2 \* \* \*"/); assert.match(viteConfig, /"0 1 \* \* \*"/);
assert.match(migration, /latest_likes/); assert.match(migration, /latest_likes/);
assert.match(migration, /collection_runs_distribution_date_idx/); assert.match(migration, /collection_runs_distribution_date_idx/);
assert.match(accountMigration, /public_account_id/); 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 () => { test("exports complete task recovery data to Excel with embedded images", async () => {
@@ -245,11 +268,70 @@ test("exports complete task recovery data to Excel with embedded images", async
assert.match(exportRoute, /screenshot_key/); assert.match(exportRoute, /screenshot_key/);
assert.match(exportRoute, /image_assets/); assert.match(exportRoute, /image_assets/);
assert.match(exportRoute, /isAdminRequest/); assert.match(exportRoute, /isAdminRequest/);
assert.match(exportRoute, /consumeMcpExportToken/);
assert.match(workbook, /xl\/drawings\/drawing1\.xml/); assert.match(workbook, /xl\/drawings\/drawing1\.xml/);
assert.match(workbook, /xl\/media\/image/); assert.match(workbook, /xl\/media\/image/);
assert.match(workbook, /oneCellAnchor/); assert.match(workbook, /oneCellAnchor/);
}); });
test("provides simple username-password login and three server-enforced roles", async () => {
const [
adminApp,
usersPage,
loginPage,
loginRoute,
logoutRoute,
usersRoute,
auth,
bootstrapRoute,
resourceExportRoute,
schema,
runtimeSchema,
migration,
] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/login/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/auth/login/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/auth/logout/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/users/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/user-auth.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/bootstrap/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/resources-export/route.ts", import.meta.url), "utf8"),
readFile(new URL("../db/schema.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0007_fantastic_sentinels.sql", import.meta.url), "utf8"),
]);
assert.match(loginPage, /登录账号/);
assert.match(loginPage, /当前|管理员分配|账号和密码/);
assert.match(loginRoute, /ensureInitialSuperAdmin/);
assert.match(loginRoute, /verifyPassword/);
assert.match(loginRoute, /Set-Cookie/);
assert.match(logoutRoute, /deleteSession/);
assert.match(logoutRoute, /clearSessionCookie/);
assert.match(auth, /PBKDF2/);
assert.match(auth, /PASSWORD_ITERATIONS = 100_000/);
assert.match(auth, /HttpOnly/);
assert.match(auth, /SameSite=Lax/);
assert.match(auth, /SELECT id FROM users WHERE role = 'super_admin' LIMIT 1/);
assert.match(usersRoute, /currentUser\.role !== "super_admin" && role !== "user"/);
assert.match(usersRoute, /DELETE FROM auth_sessions WHERE user_id =/);
assert.match(usersPage, /不开放注册和个人改密/);
assert.match(usersPage, /普通用户/);
assert.match(usersPage, /管理员/);
assert.match(adminApp, /item\.key === "resources" && currentUser\.role === "user"/);
assert.match(adminApp, /item\.key === "users" && !isManager/);
assert.match(bootstrapRoute, /principal\.user\.role === "user"/);
assert.match(bootstrapRoute, /accounts: \[\]/);
assert.match(resourceExportRoute, /isManagerRequest/);
assert.match(schema, /authSessions/);
assert.match(schema, /users_single_super_admin_idx/);
assert.match(runtimeSchema, /CREATE TABLE IF NOT EXISTS auth_sessions/);
assert.match(migration, /CREATE TABLE `users`/);
assert.match(migration, /users_single_super_admin_idx/);
});
test("filters and exports the current KOC resource result set", async () => { test("filters and exports the current KOC resource result set", async () => {
const [adminApp, exportRoute, workbook] = await Promise.all([ const [adminApp, exportRoute, workbook] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
@@ -267,7 +349,8 @@ test("filters and exports the current KOC resource result set", async () => {
assert.match(exportRoute, /小红书号\/抖音号/); assert.match(exportRoute, /小红书号\/抖音号/);
assert.match(exportRoute, /历史合作来源/); assert.match(exportRoute, /历史合作来源/);
assert.match(exportRoute, /合作社资源 · 不可直联/); assert.match(exportRoute, /合作社资源 · 不可直联/);
assert.match(exportRoute, /isAdminRequest/); assert.match(exportRoute, /isManagerRequest/);
assert.match(exportRoute, /consumeMcpExportToken/);
assert.match(workbook, /relationships\/hyperlink/); assert.match(workbook, /relationships\/hyperlink/);
}); });

View File

@@ -14,8 +14,8 @@ const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = { const localBindingConfig = {
main: "./worker/index.ts", main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"], compatibility_flags: ["nodejs_compat"],
// Cloudflare cron uses UTC. 02:00 UTC is 10:00 in Asia/Shanghai. // Cloudflare cron uses UTC. 01:00 UTC is 09:00 in Asia/Shanghai.
triggers: { crons: ["0 2 * * *"] }, triggers: { crons: ["0 1 * * *"] },
d1_databases: d1 d1_databases: d1
? [ ? [
{ {