Compare commits
8 Commits
85110bbfce
...
codex/site
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1766a90cc1 | ||
|
|
1f1887c860 | ||
|
|
5670a7939b | ||
|
|
1a9a113229 | ||
|
|
bd9b0c3871 | ||
|
|
c926a6a874 | ||
|
|
1f910c975d | ||
|
|
72f8862f8a |
@@ -1,6 +1,8 @@
|
||||
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
|
||||
KOC_MCP_API_KEY=replace-with-a-separate-long-random-secret
|
||||
|
||||
# Optional override. The production key must be stored as a runtime secret.
|
||||
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
|
||||
|
||||
108
README.md
108
README.md
@@ -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`.
|
||||
|
||||
## Included Shape
|
||||
@@ -33,63 +42,70 @@ This starter does not use `wrangler.jsonc`.
|
||||
- `examples/d1/` contains an optional D1 example surface
|
||||
- `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() {
|
||||
const requestHeaders = await headers();
|
||||
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;
|
||||
```text
|
||||
https://你的-KOC-LOOP-后台域名/api/mcp
|
||||
```
|
||||
|
||||
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
|
||||
optional or required ChatGPT sign-in:
|
||||
```text
|
||||
用这个飞书表格创建发布任务:<飞书链接>。
|
||||
任务名“8月骑手招募”,截止时间 2026-08-20。
|
||||
创建后把 KOC 领取链接发给我。
|
||||
```
|
||||
|
||||
- Use `getChatGPTUser()` for optional signed-in UI.
|
||||
- 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.
|
||||
密钥不要写入仓库、对话内容或 URL 查询参数,生产环境通过站点密钥管理配置。
|
||||
|
||||
## Useful Commands
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
formatShanghaiDate as formatDate,
|
||||
formatShanghaiToday,
|
||||
} from "../lib/date-utils";
|
||||
import type { AuthUser } from "../lib/user-auth";
|
||||
import {
|
||||
sortWithNullsLast,
|
||||
type SortDirection,
|
||||
} from "../lib/sort-utils";
|
||||
import UsersPage from "./users-page";
|
||||
|
||||
type Partner = {
|
||||
id: string;
|
||||
@@ -104,6 +110,14 @@ type DashboardData = {
|
||||
portal_url: string;
|
||||
};
|
||||
|
||||
type RecoverySortKey =
|
||||
| "publish_time"
|
||||
| "likes"
|
||||
| "collects"
|
||||
| "comments"
|
||||
| "total"
|
||||
| "collection_updated_at";
|
||||
|
||||
type FeishuPreview = {
|
||||
sheetId: string;
|
||||
sheetName: string;
|
||||
@@ -122,7 +136,8 @@ type NavKey =
|
||||
| "tasks"
|
||||
| "distributions"
|
||||
| "resources"
|
||||
| "recovery";
|
||||
| "recovery"
|
||||
| "users";
|
||||
|
||||
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
|
||||
{ key: "overview", label: "工作台", mark: "⌂" },
|
||||
@@ -130,6 +145,7 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
|
||||
{ key: "distributions", label: "内容分发", mark: "↗" },
|
||||
{ key: "resources", label: "KOC资源", mark: "◎" },
|
||||
{ key: "recovery", label: "数据回收", mark: "◫" },
|
||||
{ key: "users", label: "用户管理", mark: "♙" },
|
||||
];
|
||||
|
||||
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) {
|
||||
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 [activeNav, setActiveNav] = useState<NavKey>("overview");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -321,6 +399,13 @@ export default function Home() {
|
||||
const [sourceWorking, setSourceWorking] = useState(false);
|
||||
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
|
||||
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
|
||||
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 () => {
|
||||
try {
|
||||
@@ -398,6 +483,8 @@ export default function Home() {
|
||||
const recentDistributions = data.distributions.slice(0, 6);
|
||||
|
||||
const navigate = (key: NavKey) => {
|
||||
if (key === "resources" && currentUser.role === "user") return;
|
||||
if (key === "users" && !isManager) return;
|
||||
setActiveNav(key);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
@@ -502,7 +589,7 @@ export default function Home() {
|
||||
startDate,
|
||||
days,
|
||||
},
|
||||
`采集任务已创建,将在所选日期10:00自动执行`,
|
||||
`采集任务已创建,将在所选日期09:00自动执行`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -537,6 +624,42 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const exportResources = async (accountIds: string[]) => {
|
||||
if (accountIds.length === 0) {
|
||||
setToast("当前筛选结果为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setExportingResources(true);
|
||||
const response = await fetch("/api/resources-export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountIds }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await readApiResponse<{ error?: string }>(
|
||||
response,
|
||||
"导出失败",
|
||||
);
|
||||
throw new Error(result.error || "导出失败");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `KOC资源库-${todayInputValue().replaceAll("-", "")}.xlsx`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
setToast(`已导出 ${accountIds.length} 个KOC账号`);
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "导出失败");
|
||||
} finally {
|
||||
setExportingResources(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadScreenshot = async (
|
||||
distribution: Distribution,
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
@@ -609,6 +732,15 @@ export default function Home() {
|
||||
title: "数据回收",
|
||||
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
|
||||
},
|
||||
users: {
|
||||
title: "用户管理",
|
||||
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
|
||||
},
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
|
||||
window.location.assign("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -624,7 +756,7 @@ export default function Home() {
|
||||
|
||||
<nav className="main-nav" aria-label="主导航">
|
||||
<p className="nav-caption">运营中心</p>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
{visibleNavItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={activeNav === item.key ? "active" : ""}
|
||||
@@ -646,14 +778,14 @@ export default function Home() {
|
||||
<span>{stats.activeTasks} 个任务正在分发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-user">
|
||||
<div className="avatar mint">吴</div>
|
||||
<button className="sidebar-user" onClick={() => void logout()} title="退出登录">
|
||||
<div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
|
||||
<div>
|
||||
<strong>运营负责人</strong>
|
||||
<span>小吴</span>
|
||||
<strong>{currentUser.username}</strong>
|
||||
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
|
||||
</div>
|
||||
<span className="chevron">⌄</span>
|
||||
</div>
|
||||
<span className="chevron">退出</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
|
||||
@@ -713,6 +845,7 @@ export default function Home() {
|
||||
distributions={recentDistributions}
|
||||
tasks={data.tasks}
|
||||
onNavigate={navigate}
|
||||
showResources={currentUser.role !== "user"}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "tasks" && (
|
||||
@@ -727,14 +860,30 @@ export default function Home() {
|
||||
<DistributionPage
|
||||
tasks={data.tasks}
|
||||
distributions={data.distributions}
|
||||
canRelease={isManager}
|
||||
working={working}
|
||||
onRelease={async (distribution) =>
|
||||
runAction(
|
||||
{
|
||||
action: "release_distribution",
|
||||
distributionId: distribution.id,
|
||||
},
|
||||
`“${distribution.content_title}”已释放,可重新领取`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "resources" && (
|
||||
<ResourcesPage
|
||||
accounts={data.accounts}
|
||||
distributions={data.distributions}
|
||||
exporting={exportingResources}
|
||||
onExport={exportResources}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "users" && isManager && (
|
||||
<UsersPage currentUser={currentUser} />
|
||||
)}
|
||||
{activeNav === "recovery" && (
|
||||
<RecoveryPage
|
||||
tasks={data.tasks}
|
||||
@@ -903,6 +1052,7 @@ function OverviewPage({
|
||||
distributions,
|
||||
tasks,
|
||||
onNavigate,
|
||||
showResources,
|
||||
}: {
|
||||
stats: {
|
||||
resources: number;
|
||||
@@ -914,6 +1064,7 @@ function OverviewPage({
|
||||
distributions: Distribution[];
|
||||
tasks: Task[];
|
||||
onNavigate: (key: NavKey) => void;
|
||||
showResources: boolean;
|
||||
}) {
|
||||
const cards = [
|
||||
{
|
||||
@@ -940,7 +1091,7 @@ function OverviewPage({
|
||||
hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成",
|
||||
tone: "orange",
|
||||
},
|
||||
];
|
||||
].filter((card) => showResources || card.label !== "已沉淀账号资源");
|
||||
return (
|
||||
<>
|
||||
<div className="stat-grid">
|
||||
@@ -992,7 +1143,7 @@ function OverviewPage({
|
||||
<div>
|
||||
<p className="eyebrow">今天最需要推进</p>
|
||||
<h3>把已发布笔记的数据收回来</h3>
|
||||
<p>按任务选择采集日期,系统在当天10:00更新点赞、收藏、评论和总互动。</p>
|
||||
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。</p>
|
||||
</div>
|
||||
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
||||
</div>
|
||||
@@ -1212,11 +1363,18 @@ function TaskDetailHeader({
|
||||
function DistributionPage({
|
||||
tasks,
|
||||
distributions,
|
||||
canRelease,
|
||||
working,
|
||||
onRelease,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
canRelease: boolean;
|
||||
working: boolean;
|
||||
onRelease: (distribution: Distribution) => Promise<boolean>;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (!selectedTask) {
|
||||
return (
|
||||
@@ -1259,8 +1417,69 @@ function DistributionPage({
|
||||
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div>
|
||||
<div className="filter-chips"><button className="active">全部</button><button>待发布</button><button>采集中</button></div>
|
||||
</div>
|
||||
<DistributionTable distributions={taskDistributions} />
|
||||
<DistributionTable
|
||||
distributions={taskDistributions}
|
||||
onRelease={canRelease ? setReleaseTarget : undefined}
|
||||
/>
|
||||
</section>
|
||||
{releaseTarget && (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
|
||||
>
|
||||
<div
|
||||
className="modal-card compact release-distribution-modal"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="release-distribution-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-heading">
|
||||
<div>
|
||||
<p className="eyebrow warning">释放领取</p>
|
||||
<h2 id="release-distribution-title">确认释放这篇笔记?</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReleaseTarget(null)}
|
||||
aria-label="关闭"
|
||||
disabled={working}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="release-distribution-summary">
|
||||
<strong>{releaseTarget.content_title}</strong>
|
||||
<span>当前领取合作方:{releaseTarget.partner_name}</span>
|
||||
</div>
|
||||
<p className="release-distribution-warning">
|
||||
释放后,这篇笔记会从当前领取记录中移除并回到可领取池,其他 KOC 可以重新领取。此操作不能撤销。
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => setReleaseTarget(null)}
|
||||
disabled={working}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="release-confirm-button"
|
||||
onClick={async () => {
|
||||
const released = await onRelease(releaseTarget);
|
||||
if (released) setReleaseTarget(null);
|
||||
}}
|
||||
disabled={working}
|
||||
>
|
||||
{working ? "释放中…" : "确认释放"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1268,9 +1487,11 @@ function DistributionPage({
|
||||
function DistributionTable({
|
||||
distributions,
|
||||
compact = false,
|
||||
onRelease,
|
||||
}: {
|
||||
distributions: Distribution[];
|
||||
compact?: boolean;
|
||||
onRelease?: (distribution: Distribution) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
@@ -1283,6 +1504,7 @@ function DistributionTable({
|
||||
<th>发布时间</th>
|
||||
<th>数据状态</th>
|
||||
<th>状态</th>
|
||||
{onRelease && <th>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1316,6 +1538,21 @@ function DistributionTable({
|
||||
)}
|
||||
</td>
|
||||
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
|
||||
{onRelease && (
|
||||
<td>
|
||||
{!item.publish_url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="release-distribution-button"
|
||||
onClick={() => onRelease(item)}
|
||||
>
|
||||
释放
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1328,20 +1565,100 @@ function DistributionTable({
|
||||
function ResourcesPage({
|
||||
accounts,
|
||||
distributions,
|
||||
exporting,
|
||||
onExport,
|
||||
}: {
|
||||
accounts: Account[];
|
||||
distributions: Distribution[];
|
||||
exporting: boolean;
|
||||
onExport: (accountIds: string[]) => void;
|
||||
}) {
|
||||
const sources = (accountId: string) =>
|
||||
[...new Set(distributions.filter((item) => item.account_id === accountId).map((item) => item.partner_name))];
|
||||
const isPartnerManagedOnly = (accountId: string) => {
|
||||
const deliveries = distributions.filter(
|
||||
(item) => item.account_id === accountId,
|
||||
);
|
||||
return (
|
||||
deliveries.some((item) => item.delegation_bundle_id) &&
|
||||
deliveries.every((item) => item.delegation_bundle_id)
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [platformFilter, setPlatformFilter] = useState("全部平台");
|
||||
const [ipFilter, setIpFilter] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState("");
|
||||
const resourceAccounts = useMemo(() => {
|
||||
const deliveriesByAccount = new Map<string, Distribution[]>();
|
||||
distributions.forEach((distribution) => {
|
||||
if (!distribution.account_id) return;
|
||||
const current = deliveriesByAccount.get(distribution.account_id) ?? [];
|
||||
current.push(distribution);
|
||||
deliveriesByAccount.set(distribution.account_id, current);
|
||||
});
|
||||
return accounts.map((account) => {
|
||||
const deliveries = deliveriesByAccount.get(account.id) ?? [];
|
||||
return {
|
||||
account,
|
||||
sources: [
|
||||
...new Set(
|
||||
deliveries.map((item) => item.partner_name).filter(Boolean),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right, "zh-CN")),
|
||||
partnerManagedOnly:
|
||||
deliveries.some((item) => item.delegation_bundle_id) &&
|
||||
deliveries.every((item) => item.delegation_bundle_id),
|
||||
};
|
||||
});
|
||||
}, [accounts, distributions]);
|
||||
const platformOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, "zh-CN")),
|
||||
[accounts],
|
||||
);
|
||||
const ipOptions = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(
|
||||
accounts.map((account) => account.ip_location || "待识别"),
|
||||
),
|
||||
].sort((left, right) => {
|
||||
if (left === "待识别") return 1;
|
||||
if (right === "待识别") return -1;
|
||||
return left.localeCompare(right, "zh-CN");
|
||||
}),
|
||||
[accounts],
|
||||
);
|
||||
const sourceOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(resourceAccounts.flatMap((item) => item.sources))].sort(
|
||||
(left, right) => left.localeCompare(right, "zh-CN"),
|
||||
),
|
||||
[resourceAccounts],
|
||||
);
|
||||
const filteredAccounts = useMemo(() => {
|
||||
const keyword = query.trim().toLocaleLowerCase("zh-CN");
|
||||
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
|
||||
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
|
||||
return resourceAccounts.filter(({ account, sources }) => {
|
||||
const searchable = `${account.nickname} ${account.public_account_id}`.toLocaleLowerCase(
|
||||
"zh-CN",
|
||||
);
|
||||
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
|
||||
"zh-CN",
|
||||
);
|
||||
return (
|
||||
(!keyword || searchable.includes(keyword)) &&
|
||||
(platformFilter === "全部平台" ||
|
||||
account.platform === platformFilter) &&
|
||||
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
|
||||
(!sourceKeyword ||
|
||||
sources.some((source) =>
|
||||
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
|
||||
))
|
||||
);
|
||||
});
|
||||
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
|
||||
const hasActiveFilters =
|
||||
Boolean(query.trim()) ||
|
||||
platformFilter !== "全部平台" ||
|
||||
Boolean(ipFilter.trim()) ||
|
||||
Boolean(sourceFilter.trim());
|
||||
const clearFilters = () => {
|
||||
setQuery("");
|
||||
setPlatformFilter("全部平台");
|
||||
setIpFilter("");
|
||||
setSourceFilter("");
|
||||
};
|
||||
return (
|
||||
<div className="stack">
|
||||
@@ -1362,10 +1679,87 @@ function ResourcesPage({
|
||||
<section className="panel table-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>账号资源库</h2><p>只展示真实交付过的账号</p></div>
|
||||
<div className="filter-chips"><button className="active">全部平台</button><button>小红书</button></div>
|
||||
<div className="filter-chips">
|
||||
<button
|
||||
className={platformFilter === "全部平台" ? "active" : ""}
|
||||
onClick={() => setPlatformFilter("全部平台")}
|
||||
>
|
||||
全部平台
|
||||
</button>
|
||||
{platformOptions.map((platform) => (
|
||||
<button
|
||||
className={platformFilter === platform ? "active" : ""}
|
||||
key={platform}
|
||||
onClick={() => setPlatformFilter(platform)}
|
||||
>
|
||||
{platform}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-toolbar">
|
||||
<div className="resource-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
aria-label="搜索账号名称或账号ID"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索账号名称 / 账号ID"
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="resource-filter-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
aria-label="模糊搜索IP地区"
|
||||
list="resource-ip-options"
|
||||
onChange={(event) => setIpFilter(event.target.value)}
|
||||
placeholder="搜索IP地区"
|
||||
type="search"
|
||||
value={ipFilter}
|
||||
/>
|
||||
<datalist id="resource-ip-options">
|
||||
{ipOptions.map((ip) => <option key={ip} value={ip} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="resource-filter-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
aria-label="模糊搜索合作来源"
|
||||
list="resource-source-options"
|
||||
onChange={(event) => setSourceFilter(event.target.value)}
|
||||
placeholder="搜索合作来源"
|
||||
type="search"
|
||||
value={sourceFilter}
|
||||
/>
|
||||
<datalist id="resource-source-options">
|
||||
{sourceOptions.map((source) => (
|
||||
<option key={source} value={source} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<button
|
||||
className="resource-clear-button"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
<div className="resource-toolbar-summary">
|
||||
<span>共 <b>{filteredAccounts.length}</b> 个结果</span>
|
||||
<button
|
||||
className="export-data-button"
|
||||
disabled={exporting || filteredAccounts.length === 0}
|
||||
onClick={() =>
|
||||
onExport(filteredAccounts.map((item) => item.account.id))
|
||||
}
|
||||
>
|
||||
{exporting ? "正在导出…" : "导出筛选结果"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-grid">
|
||||
{accounts.map((account) => (
|
||||
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
|
||||
<article className="resource-card" key={account.id}>
|
||||
<div className="resource-card-head">
|
||||
<div className={`avatar xlarge ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
|
||||
@@ -1383,8 +1777,8 @@ function ResourcesPage({
|
||||
<div className="resource-source">
|
||||
<span>历史合作来源</span>
|
||||
<div>
|
||||
{sources(account.id).map((source) => <b key={source}>{source}</b>)}
|
||||
{isPartnerManagedOnly(account.id) && (
|
||||
{sources.map((source) => <b key={source}>{source}</b>)}
|
||||
{partnerManagedOnly && (
|
||||
<b className="partner-managed">合作社资源 · 不可直联</b>
|
||||
)}
|
||||
</div>
|
||||
@@ -1396,6 +1790,15 @@ function ResourcesPage({
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{filteredAccounts.length === 0 && (
|
||||
<div className="resource-empty">
|
||||
<strong>没有符合条件的KOC</strong>
|
||||
<span>可以调整搜索内容或清空筛选条件</span>
|
||||
<button className="ghost-button" onClick={clearFilters}>
|
||||
清空筛选
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -1429,6 +1832,10 @@ function RecoveryPage({
|
||||
exportingTaskId: string | null;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [recoverySort, setRecoverySort] = useState<{
|
||||
column: RecoverySortKey | null;
|
||||
direction: SortDirection;
|
||||
}>({ column: null, direction: "desc" });
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (!selectedTask) {
|
||||
return (
|
||||
@@ -1453,6 +1860,23 @@ function RecoveryPage({
|
||||
const failedCollectionCount = taskDistributions.filter(
|
||||
(item) => item.collection_status === "failed",
|
||||
).length;
|
||||
const sortedTaskDistributions = recoverySort.column
|
||||
? sortRecoveryDistributions(
|
||||
taskDistributions,
|
||||
recoverySort.column,
|
||||
recoverySort.direction,
|
||||
)
|
||||
: taskDistributions;
|
||||
const changeRecoverySort = (column: RecoverySortKey) => {
|
||||
setRecoverySort((current) =>
|
||||
current.column === column
|
||||
? {
|
||||
column,
|
||||
direction: current.direction === "desc" ? "asc" : "desc",
|
||||
}
|
||||
: { column, direction: "desc" },
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="stack">
|
||||
<TaskDetailHeader
|
||||
@@ -1499,19 +1923,55 @@ function RecoveryPage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>内容 / 发布账号</th>
|
||||
<th>发布时间</th>
|
||||
<th>点赞</th>
|
||||
<th>收藏</th>
|
||||
<th>评论</th>
|
||||
<th>总互动</th>
|
||||
<th>数据更新时间</th>
|
||||
<SortableRecoveryHeader
|
||||
label="发布时间"
|
||||
column="publish_time"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="点赞"
|
||||
column="likes"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="收藏"
|
||||
column="collects"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="评论"
|
||||
column="comments"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="总互动"
|
||||
column="total"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<SortableRecoveryHeader
|
||||
label="数据更新时间"
|
||||
column="collection_updated_at"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
<th>采集状态</th>
|
||||
<th>创作者截图</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{taskDistributions.map((item) => {
|
||||
{sortedTaskDistributions.map((item) => {
|
||||
const metrics = latestPublicMetrics(item);
|
||||
const noteUrl = xhsPublishUrl(item.publish_url);
|
||||
const hasMetrics = metrics.likes !== null;
|
||||
@@ -1674,7 +2134,7 @@ function CollectionScheduleCard({
|
||||
<div>
|
||||
<p className="eyebrow">自动采集计划</p>
|
||||
<h2>选择开始日期与采集日</h2>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间10:00自动采集。</span>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
|
||||
</div>
|
||||
<div className="schedule-save-area">
|
||||
<span>已选择 {days.length} 天</span>
|
||||
|
||||
@@ -19,12 +19,17 @@ import {
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { isManagerRequest } from "../../../lib/user-auth";
|
||||
import {
|
||||
FeishuSourceError,
|
||||
readFeishuSource,
|
||||
type FeishuBindings,
|
||||
type FeishuSource,
|
||||
} from "../../../lib/feishu-client";
|
||||
import { createDistributionTask } from "../../../lib/task-service";
|
||||
import {
|
||||
DistributionReleaseError,
|
||||
releaseUnfinishedDistribution,
|
||||
} from "../../../lib/distribution-release-service";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
@@ -38,78 +43,8 @@ function numberValue(value: unknown, fallback = 0) {
|
||||
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) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const body = (await request.json()) as ActionBody;
|
||||
@@ -140,11 +75,15 @@ export async function POST(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
await createDistributionTask(
|
||||
{
|
||||
feishuUrl: String(body.feishuUrl ?? "").trim(),
|
||||
name,
|
||||
brand,
|
||||
dueAt,
|
||||
},
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await createTaskFromSource(source, name, brand, dueAt);
|
||||
} else if (body.action === "claim") {
|
||||
const partnerId = String(body.partnerId ?? "");
|
||||
const taskId = String(body.taskId ?? "");
|
||||
@@ -188,6 +127,12 @@ export async function POST(request: Request) {
|
||||
.bind(available.results.length, partnerId),
|
||||
);
|
||||
await db.batch(statements);
|
||||
} else if (body.action === "release_distribution") {
|
||||
if (!(await isManagerRequest(request))) return adminForbidden();
|
||||
await releaseUnfinishedDistribution(
|
||||
db,
|
||||
String(body.distributionId ?? "").trim(),
|
||||
);
|
||||
} else if (body.action === "save_collection_schedule") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const startDate = String(body.startDate ?? "").trim();
|
||||
@@ -245,7 +190,7 @@ export async function POST(request: Request) {
|
||||
AND publish_url != ''`,
|
||||
)
|
||||
.bind(
|
||||
`已安排${days.length}个采集日,每日10:00执行`,
|
||||
`已安排${days.length}个采集日,每日09:00执行`,
|
||||
taskId,
|
||||
),
|
||||
]);
|
||||
@@ -388,7 +333,13 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "操作失败" },
|
||||
{ status: error instanceof FeishuSourceError ? error.status : 500 },
|
||||
{
|
||||
status:
|
||||
error instanceof FeishuSourceError ||
|
||||
error instanceof DistributionReleaseError
|
||||
? error.status
|
||||
: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
app/api/auth/login/route.ts
Normal file
47
app/api/auth/login/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/api/auth/logout/route.ts
Normal file
17
app/api/auth/logout/route.ts
Normal 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) } },
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
getRawDb,
|
||||
seedIfEmpty,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
const principal = await getRequestPrincipal(request);
|
||||
if (!principal) return authForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
await seedIfEmpty();
|
||||
@@ -60,7 +61,12 @@ export async function GET(request: Request) {
|
||||
} else {
|
||||
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) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "加载失败" },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const form = await request.formData();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const distributionId = new URL(request.url).searchParams
|
||||
|
||||
234
app/api/mcp/route.ts
Normal file
234
app/api/mcp/route.ts
Normal 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;
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type RecoveryWorkbookImage,
|
||||
type RecoveryWorkbookRow,
|
||||
} from "../../../lib/recovery-workbook";
|
||||
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
@@ -218,10 +219,17 @@ function exactArrayBuffer(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
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();
|
||||
const taskId = new URL(request.url).searchParams.get("task")?.trim() || "";
|
||||
const taskId = tokenPayload
|
||||
? String(tokenPayload.taskId ?? "").trim()
|
||||
: url.searchParams.get("task")?.trim() || "";
|
||||
if (!taskId) {
|
||||
return Response.json({ error: "缺少导出任务" }, { status: 400 });
|
||||
}
|
||||
|
||||
214
app/api/resources-export/route.ts
Normal file
214
app/api/resources-export/route.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
||||
import { parseStoredDate } from "../../../lib/date-utils";
|
||||
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
||||
import {
|
||||
buildRecoveryWorkbook,
|
||||
type RecoveryWorkbookRow,
|
||||
} from "../../../lib/recovery-workbook";
|
||||
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type AccountRow = {
|
||||
id: string;
|
||||
platform: string;
|
||||
public_account_id: string;
|
||||
nickname: string;
|
||||
profile_url: string;
|
||||
ip_location: string;
|
||||
followers: number;
|
||||
post_count: number;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
type CooperationRow = {
|
||||
account_id: string;
|
||||
partner_name: string;
|
||||
delegation_bundle_id: string | null;
|
||||
};
|
||||
|
||||
function formatExportDate(value: string) {
|
||||
const date = parseStoredDate(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function exactArrayBuffer(bytes: Uint8Array) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function normalizeAccountIds(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 5000);
|
||||
}
|
||||
|
||||
async function exportAccounts(accountIds: string[]) {
|
||||
try {
|
||||
if (accountIds.length === 0) {
|
||||
return Response.json({ error: "当前筛选结果为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const [accountResult, cooperationResult] = await Promise.all([
|
||||
db.prepare("SELECT * FROM accounts ORDER BY last_seen_at DESC").all<AccountRow>(),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT
|
||||
d.account_id,
|
||||
p.name AS partner_name,
|
||||
d.delegation_bundle_id
|
||||
FROM distributions d
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
WHERE d.account_id IS NOT NULL`,
|
||||
)
|
||||
.all<CooperationRow>(),
|
||||
]);
|
||||
const accountIdSet = new Set(accountIds);
|
||||
const order = new Map(accountIds.map((id, index) => [id, index]));
|
||||
const accounts = accountResult.results
|
||||
.filter((account) => accountIdSet.has(account.id))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(order.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(order.get(right.id) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
if (accounts.length === 0) {
|
||||
return Response.json({ error: "没有找到可导出的账号" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cooperationByAccount = new Map<string, CooperationRow[]>();
|
||||
for (const cooperation of cooperationResult.results) {
|
||||
if (!accountIdSet.has(cooperation.account_id)) continue;
|
||||
const current = cooperationByAccount.get(cooperation.account_id) ?? [];
|
||||
current.push(cooperation);
|
||||
cooperationByAccount.set(cooperation.account_id, current);
|
||||
}
|
||||
const headers = [
|
||||
"序号",
|
||||
"平台",
|
||||
"账号名称",
|
||||
"小红书号/抖音号",
|
||||
"账号主页",
|
||||
"IP地",
|
||||
"粉丝数",
|
||||
"合作发布数",
|
||||
"历史合作来源",
|
||||
"资源归属",
|
||||
"首次合作时间",
|
||||
"最近合作时间",
|
||||
];
|
||||
const rows: RecoveryWorkbookRow[] = accounts.map((account, index) => {
|
||||
const cooperation = cooperationByAccount.get(account.id) ?? [];
|
||||
const sources = [...new Set(cooperation.map((item) => item.partner_name))];
|
||||
const partnerManagedOnly =
|
||||
cooperation.some((item) => item.delegation_bundle_id) &&
|
||||
cooperation.every((item) => item.delegation_bundle_id);
|
||||
return {
|
||||
cells: [
|
||||
index + 1,
|
||||
account.platform,
|
||||
account.nickname,
|
||||
account.public_account_id || "待识别",
|
||||
account.profile_url || "",
|
||||
account.ip_location || "待识别",
|
||||
account.followers,
|
||||
account.post_count,
|
||||
sources.join("、"),
|
||||
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
|
||||
formatExportDate(account.first_seen_at),
|
||||
formatExportDate(account.last_seen_at),
|
||||
],
|
||||
images: [],
|
||||
hyperlinks: account.profile_url
|
||||
? [{ column: 4, url: account.profile_url }]
|
||||
: [],
|
||||
};
|
||||
});
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: "KOC资源库",
|
||||
headers,
|
||||
columnWidths: [
|
||||
9,
|
||||
12,
|
||||
22,
|
||||
22,
|
||||
44,
|
||||
14,
|
||||
14,
|
||||
14,
|
||||
32,
|
||||
22,
|
||||
21,
|
||||
21,
|
||||
],
|
||||
rows,
|
||||
});
|
||||
const date = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
.format(new Date())
|
||||
.replace(/\D/g, "");
|
||||
const fileName = `KOC资源库-${date}.xlsx`;
|
||||
return new Response(exactArrayBuffer(workbook), {
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="koc-resources.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "导出失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const form = await request.formData();
|
||||
|
||||
164
app/api/users/route.ts
Normal file
164
app/api/users/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
622
app/globals.css
622
app/globals.css
@@ -79,6 +79,135 @@ body {
|
||||
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 {
|
||||
margin: 0;
|
||||
background: var(--canvas);
|
||||
@@ -293,6 +422,17 @@ a {
|
||||
margin-top: 12px;
|
||||
padding: 10px 8px 0;
|
||||
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 {
|
||||
@@ -1426,6 +1566,98 @@ a {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.resource-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
margin: -3px 0 18px;
|
||||
padding: 13px;
|
||||
border: 1px solid #e9eeeb;
|
||||
border-radius: 12px;
|
||||
background: #f8faf8;
|
||||
}
|
||||
|
||||
.resource-search {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 230px;
|
||||
flex: 1 1 280px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dfe6e2;
|
||||
border-radius: 9px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.resource-search:focus-within,
|
||||
.resource-filter-search:focus-within {
|
||||
border-color: #74b79f;
|
||||
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
|
||||
}
|
||||
|
||||
.resource-search > span,
|
||||
.resource-filter-search > span {
|
||||
color: #83918c;
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.resource-search input,
|
||||
.resource-filter-search input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.resource-filter-search {
|
||||
display: flex;
|
||||
flex: 0 1 180px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 38px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #dfe6e2;
|
||||
border-radius: 9px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.resource-clear-button {
|
||||
height: 38px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
color: var(--green-deep);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.resource-clear-button:disabled {
|
||||
color: #a5aeaa;
|
||||
}
|
||||
|
||||
.resource-toolbar-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
color: #8c9894;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-toolbar-summary b {
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -1580,6 +1812,27 @@ a {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.resource-empty {
|
||||
display: flex;
|
||||
min-height: 240px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
color: #939e9a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resource-empty strong {
|
||||
color: #354640;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.resource-empty span {
|
||||
margin-bottom: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.recovery-legend {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -1635,6 +1888,41 @@ a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recovery-sort-button {
|
||||
display: inline-flex;
|
||||
min-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recovery-sort-button b {
|
||||
color: #aab2af;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recovery-sort-button:hover,
|
||||
.recovery-sort-button.active {
|
||||
color: #168565;
|
||||
}
|
||||
|
||||
.recovery-sort-button.active b {
|
||||
color: #168565;
|
||||
}
|
||||
|
||||
.recovery-sort-button:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 2px solid rgba(22, 133, 101, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.recovery-content {
|
||||
max-width: 360px;
|
||||
}
|
||||
@@ -2622,6 +2910,258 @@ label small {
|
||||
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 {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
@@ -2749,6 +3289,11 @@ label small {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.resource-toolbar-summary {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.task-scope-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -2760,9 +3305,31 @@ label small {
|
||||
.schedule-grid {
|
||||
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) {
|
||||
.login-page {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.user-management-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 180ms ease;
|
||||
@@ -2839,6 +3406,42 @@ label small {
|
||||
}
|
||||
|
||||
@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 {
|
||||
height: 62px;
|
||||
}
|
||||
@@ -2876,6 +3479,25 @@ label small {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.resource-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.resource-search,
|
||||
.resource-filter-search {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.resource-clear-button {
|
||||
padding-inline: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.resource-toolbar-summary {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.collection-schedule-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
69
app/login/page.tsx
Normal file
69
app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
app/page.tsx
25
app/page.tsx
@@ -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 { isAdminEmail } from "../lib/admin-auth";
|
||||
import { getUserFromCookieHeader } from "../lib/user-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const user = await requireChatGPTUser("/");
|
||||
|
||||
if (!isAdminEmail(user.email)) {
|
||||
return (
|
||||
<main className="admin-access-denied">
|
||||
<section>
|
||||
<p>KOC LOOP</p>
|
||||
<h1>当前账号没有后台权限</h1>
|
||||
<span>请切换为管理员账号后再试。</span>
|
||||
<a href={chatGPTSignOutPath("/")}>切换账号</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminApp />;
|
||||
const requestHeaders = await headers();
|
||||
const user = await getUserFromCookieHeader(requestHeaders.get("cookie"));
|
||||
if (!user) redirect("/login");
|
||||
return <AdminApp currentUser={user} />;
|
||||
}
|
||||
|
||||
312
app/users-page.tsx
Normal file
312
app/users-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
db/schema.ts
46
db/schema.ts
@@ -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
50
design-qa.md
Normal 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
|
||||
332
docs/KOC LOOP 部署指南.md
Normal file
332
docs/KOC LOOP 部署指南.md
Normal 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`
|
||||
22
drizzle/0007_fantastic_sentinels.sql
Normal file
22
drizzle/0007_fantastic_sentinels.sql
Normal 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';
|
||||
9
drizzle/0008_worried_ultimatum.sql
Normal file
9
drizzle/0008_worried_ultimatum.sql
Normal 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`);
|
||||
1102
drizzle/meta/0007_snapshot.json
Normal file
1102
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1156
drizzle/meta/0008_snapshot.json
Normal file
1156
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,20 @@
|
||||
"when": 1785380450391,
|
||||
"tag": "0006_moaning_dark_phoenix",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,35 +1,4 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
export function isAdminEmail(input: string | null | undefined) {
|
||||
const allowedEmail = String(
|
||||
(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 });
|
||||
}
|
||||
export {
|
||||
authForbidden as adminForbidden,
|
||||
isAppRequest as isAdminRequest,
|
||||
} from "./user-auth";
|
||||
|
||||
25
lib/collection-schedule.ts
Normal file
25
lib/collection-schedule.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
function shanghaiDateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function shanghaiHourFromTimestamp(timestamp: number) {
|
||||
return Number(
|
||||
new Date(timestamp + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(11, 13),
|
||||
);
|
||||
}
|
||||
|
||||
export function isCollectionScheduleDue(
|
||||
scheduledDate: string,
|
||||
timestamp: number,
|
||||
) {
|
||||
const currentDate = shanghaiDateFromTimestamp(timestamp);
|
||||
const currentHour = shanghaiHourFromTimestamp(timestamp);
|
||||
return (
|
||||
scheduledDate < currentDate ||
|
||||
(scheduledDate === currentDate && currentHour >= 9)
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
type CollectionMcpConfig,
|
||||
} from "./mcp-collection-client";
|
||||
import { uid } from "./mvp-db";
|
||||
import { isCollectionScheduleDue } from "./collection-schedule";
|
||||
|
||||
export { isCollectionScheduleDue } from "./collection-schedule";
|
||||
|
||||
type DistributionForCollection = {
|
||||
id: string;
|
||||
@@ -51,21 +54,11 @@ export function shanghaiDateFromTimestamp(timestamp: number) {
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function shanghaiHourFromTimestamp(timestamp: number) {
|
||||
return Number(
|
||||
new Date(timestamp + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(11, 13),
|
||||
);
|
||||
}
|
||||
|
||||
function dueSchedules(
|
||||
startDate: string,
|
||||
days: number[],
|
||||
timestamp: number,
|
||||
) {
|
||||
const currentDate = shanghaiDateFromTimestamp(timestamp);
|
||||
const currentHour = shanghaiHourFromTimestamp(timestamp);
|
||||
return days
|
||||
.map((scheduleDay) => ({
|
||||
scheduleDay,
|
||||
@@ -77,8 +70,7 @@ function dueSchedules(
|
||||
): value is { scheduleDay: number; scheduledDate: string } =>
|
||||
Boolean(
|
||||
value.scheduledDate &&
|
||||
(value.scheduledDate < currentDate ||
|
||||
(value.scheduledDate === currentDate && currentHour >= 10)),
|
||||
isCollectionScheduleDue(value.scheduledDate, timestamp),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -130,8 +122,8 @@ export async function createCollectionRunTasks(
|
||||
distribution.id,
|
||||
scheduledDate,
|
||||
scheduleDay,
|
||||
`${scheduledDate}T10:00:00+08:00`,
|
||||
`等待第${scheduleDay}天 10:00自动采集`,
|
||||
`${scheduledDate}T09:00:00+08:00`,
|
||||
`等待第${scheduleDay}天 09:00自动采集`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -161,7 +153,7 @@ export async function collectDistributionMetrics(
|
||||
if (!current) throw new Error("分发记录不存在");
|
||||
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
|
||||
|
||||
const scheduledAt = `${scheduledDate}T10:00:00+08:00`;
|
||||
const scheduledAt = `${scheduledDate}T09:00:00+08:00`;
|
||||
const runId = uid("run");
|
||||
await db
|
||||
.prepare(
|
||||
@@ -230,7 +222,7 @@ export async function collectDistributionMetrics(
|
||||
const dayWeight = scheduleDay ?? 1;
|
||||
const successDescription =
|
||||
source === "automatic"
|
||||
? `成功 · 第${dayWeight}天 10:00自动采集`
|
||||
? `成功 · 第${dayWeight}天 09:00自动采集`
|
||||
: source === "catchup"
|
||||
? `成功 · 第${dayWeight}天自动追采`
|
||||
: "成功 · 手动采集";
|
||||
|
||||
137
lib/distribution-release-service.ts
Normal file
137
lib/distribution-release-service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
type ReleasableDistribution = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
content_id: string;
|
||||
content_title: string;
|
||||
partner_id: string;
|
||||
partner_name: string;
|
||||
claim_id: string | null;
|
||||
delegation_bundle_id: string | null;
|
||||
publish_url: string | null;
|
||||
};
|
||||
|
||||
export class DistributionReleaseError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "DistributionReleaseError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function distributionReleaseBlockReason(input: {
|
||||
publishUrl?: string | null;
|
||||
resultSubmittedAt?: string | null;
|
||||
}) {
|
||||
if (input.publishUrl) return "已回填发布链接的笔记不能释放";
|
||||
if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function releaseUnfinishedDistribution(
|
||||
db: D1Database,
|
||||
distributionId: string,
|
||||
) {
|
||||
if (!distributionId) {
|
||||
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
|
||||
}
|
||||
|
||||
const distribution = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.task_id,
|
||||
d.content_id,
|
||||
c.title AS content_title,
|
||||
d.partner_id,
|
||||
p.name AS partner_name,
|
||||
d.claim_id,
|
||||
d.delegation_bundle_id,
|
||||
d.publish_url
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
WHERE d.id = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<ReleasableDistribution>();
|
||||
|
||||
if (!distribution) {
|
||||
throw new DistributionReleaseError("领取记录不存在或已被释放", 404);
|
||||
}
|
||||
const blocked = distributionReleaseBlockReason({
|
||||
publishUrl: distribution.publish_url,
|
||||
});
|
||||
if (blocked) throw new DistributionReleaseError(blocked, 409);
|
||||
|
||||
const statements: D1PreparedStatement[] = [
|
||||
db
|
||||
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||
.bind(distribution.id),
|
||||
db.prepare("DELETE FROM distributions WHERE id = ?").bind(distribution.id),
|
||||
db
|
||||
.prepare("UPDATE contents SET status = 'available' WHERE id = ?")
|
||||
.bind(distribution.content_id),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET claimed_quantity = MAX(claimed_quantity - 1, 0)
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distribution.task_id),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners
|
||||
SET claimed_total = MAX(claimed_total - 1, 0)
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distribution.partner_id),
|
||||
];
|
||||
|
||||
if (distribution.delegation_bundle_id) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE delegation_bundles
|
||||
SET quantity = MAX(quantity - 1, 0),
|
||||
status = CASE WHEN quantity <= 1 THEN 'revoked' ELSE status END,
|
||||
revoked_at = CASE WHEN quantity <= 1 THEN CURRENT_TIMESTAMP ELSE revoked_at END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distribution.delegation_bundle_id),
|
||||
);
|
||||
}
|
||||
|
||||
if (distribution.claim_id) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE claims
|
||||
SET quantity = MAX(quantity - 1, 0)
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distribution.claim_id),
|
||||
db
|
||||
.prepare(
|
||||
`DELETE FROM claims
|
||||
WHERE id = ?
|
||||
AND quantity <= 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM distributions WHERE claim_id = ?
|
||||
)`,
|
||||
)
|
||||
.bind(distribution.claim_id, distribution.claim_id),
|
||||
);
|
||||
}
|
||||
|
||||
await db.batch(statements);
|
||||
return {
|
||||
distributionId: distribution.id,
|
||||
taskId: distribution.task_id,
|
||||
contentId: distribution.content_id,
|
||||
contentTitle: distribution.content_title,
|
||||
partnerName: distribution.partner_name,
|
||||
};
|
||||
}
|
||||
75
lib/mcp-export-token.ts
Normal file
75
lib/mcp-export-token.ts
Normal 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
464
lib/mcp-operations.ts
Normal 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
247
lib/mcp-tools.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -149,6 +149,29 @@ export async function ensureSchema(database?: D1Database) {
|
||||
completed_at TEXT,
|
||||
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) {
|
||||
@@ -305,6 +328,30 @@ export async function ensureSchema(database?: D1Database) {
|
||||
ON collection_runs(task_id, scheduled_date)`,
|
||||
)
|
||||
.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
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
|
||||
@@ -14,6 +14,10 @@ export type RecoveryWorkbookRow = {
|
||||
column: number;
|
||||
image: RecoveryWorkbookImage;
|
||||
}>;
|
||||
hyperlinks?: Array<{
|
||||
column: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type WorkbookOptions = {
|
||||
@@ -53,6 +57,15 @@ function safeSheetName(value: string) {
|
||||
return (cleaned || "数据回收").slice(0, 31);
|
||||
}
|
||||
|
||||
function safeHyperlink(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function imageFormat(contentType: string, bytes: Uint8Array) {
|
||||
const normalized = contentType.toLowerCase();
|
||||
if (normalized.includes("png") || (bytes[0] === 0x89 && bytes[1] === 0x50)) {
|
||||
@@ -139,6 +152,15 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
||||
const imageEntries = options.rows.flatMap((row, rowIndex) =>
|
||||
row.images.map((item) => ({ ...item, row: rowIndex + 1 })),
|
||||
);
|
||||
const hyperlinkEntries = options.rows.flatMap((row, rowIndex) =>
|
||||
(row.hyperlinks ?? [])
|
||||
.map((item) => ({
|
||||
...item,
|
||||
row: rowIndex + 2,
|
||||
url: safeHyperlink(item.url),
|
||||
}))
|
||||
.filter((item) => item.url),
|
||||
);
|
||||
const lastColumn = columnName(Math.max(0, options.headers.length - 1));
|
||||
const lastRow = Math.max(1, options.rows.length + 1);
|
||||
const headerCells = options.headers
|
||||
@@ -148,6 +170,9 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
||||
.map((row, rowIndex) => {
|
||||
const number = rowIndex + 2;
|
||||
const imageColumns = new Set(row.images.map((item) => item.column));
|
||||
const hyperlinkColumns = new Set(
|
||||
(row.hyperlinks ?? []).map((item) => item.column),
|
||||
);
|
||||
const cells = options.headers
|
||||
.map((_, columnIndex) => {
|
||||
const reference = `${columnName(columnIndex)}${number}`;
|
||||
@@ -157,7 +182,11 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
||||
}
|
||||
return typeof value === "number"
|
||||
? numberCell(reference, value, 3)
|
||||
: inlineCell(reference, value, 2);
|
||||
: inlineCell(
|
||||
reference,
|
||||
value,
|
||||
hyperlinkColumns.has(columnIndex) ? 5 : 2,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
const rowHeight = row.images.length > 0 ? IMAGE_ROW_HEIGHT : 42;
|
||||
@@ -199,7 +228,28 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
||||
.map(([extension, contentType]) => `<Default Extension="${extension}" ContentType="${contentType}"/>`)
|
||||
.join("");
|
||||
const contentTypes = `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${imageContentTypes}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${imageEntries.length ? '<Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>' : ""}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
|
||||
const worksheet = `${XML_HEADER}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><dimension ref="A1:${lastColumn}${lastRow}"/><sheetViews><sheetView workbookViewId="0"><pane xSplit="3" ySplit="1" topLeftCell="D2" activePane="bottomRight" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="18"/><cols>${columns}</cols><sheetData><row r="1" ht="30" customHeight="1">${headerCells}</row>${dataRows}</sheetData><autoFilter ref="A1:${lastColumn}${lastRow}"/>${imageEntries.length ? '<drawing r:id="rId1"/>' : ""}</worksheet>`;
|
||||
const hyperlinkRelationshipOffset = imageEntries.length ? 2 : 1;
|
||||
const hyperlinksXml = hyperlinkEntries.length
|
||||
? `<hyperlinks>${hyperlinkEntries
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`<hyperlink ref="${columnName(entry.column)}${entry.row}" r:id="rId${hyperlinkRelationshipOffset + index}"/>`,
|
||||
)
|
||||
.join("")}</hyperlinks>`
|
||||
: "";
|
||||
const worksheet = `${XML_HEADER}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><dimension ref="A1:${lastColumn}${lastRow}"/><sheetViews><sheetView workbookViewId="0"><pane xSplit="3" ySplit="1" topLeftCell="D2" activePane="bottomRight" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="18"/><cols>${columns}</cols><sheetData><row r="1" ht="30" customHeight="1">${headerCells}</row>${dataRows}</sheetData><autoFilter ref="A1:${lastColumn}${lastRow}"/>${hyperlinksXml}${imageEntries.length ? '<drawing r:id="rId1"/>' : ""}</worksheet>`;
|
||||
|
||||
const sheetRelationships = [
|
||||
...(imageEntries.length
|
||||
? [
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>',
|
||||
]
|
||||
: []),
|
||||
...hyperlinkEntries.map(
|
||||
(entry, index) =>
|
||||
`<Relationship Id="rId${hyperlinkRelationshipOffset + index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="${cleanXmlText(entry.url)}" TargetMode="External"/>`,
|
||||
),
|
||||
];
|
||||
|
||||
const files: Record<string, Uint8Array> = {
|
||||
"[Content_Types].xml": strToU8(contentTypes),
|
||||
@@ -208,11 +258,13 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
||||
"docProps/core.xml": strToU8(`${XML_HEADER}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:creator>KOC LOOP</dc:creator><cp:lastModifiedBy>KOC LOOP</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created></cp:coreProperties>`),
|
||||
"xl/workbook.xml": strToU8(`${XML_HEADER}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${cleanXmlText(sheetName)}" sheetId="1" r:id="rId1"/></sheets></workbook>`),
|
||||
"xl/_rels/workbook.xml.rels": strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`),
|
||||
"xl/styles.xml": strToU8(`${XML_HEADER}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="3"><font><sz val="10"/><name val="Arial"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Arial"/></font><font><color rgb="FF153B31"/><sz val="10"/><name val="Arial"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF1F8F6B"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFDDE5E1"/></left><right style="thin"><color rgb="FFDDE5E1"/></right><top style="thin"><color rgb="FFDDE5E1"/></top><bottom style="thin"><color rgb="FFDDE5E1"/></bottom><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`),
|
||||
"xl/styles.xml": strToU8(`${XML_HEADER}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="4"><font><sz val="10"/><name val="Arial"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Arial"/></font><font><color rgb="FF153B31"/><sz val="10"/><name val="Arial"/></font><font><u/><color rgb="FF1F8F6B"/><sz val="10"/><name val="Arial"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF1F8F6B"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFDDE5E1"/></left><right style="thin"><color rgb="FFDDE5E1"/></right><top style="thin"><color rgb="FFDDE5E1"/></top><bottom style="thin"><color rgb="FFDDE5E1"/></bottom><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="6"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf><xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`),
|
||||
"xl/worksheets/sheet1.xml": strToU8(worksheet),
|
||||
};
|
||||
if (sheetRelationships.length) {
|
||||
files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheetRelationships.join("")}</Relationships>`);
|
||||
}
|
||||
if (imageEntries.length) {
|
||||
files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>`);
|
||||
files["xl/drawings/drawing1.xml"] = strToU8(drawingXml);
|
||||
files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(drawingRelationships);
|
||||
imageEntries.forEach((entry, index) => {
|
||||
|
||||
26
lib/sort-utils.ts
Normal file
26
lib/sort-utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export function sortWithNullsLast<T>(
|
||||
items: T[],
|
||||
valueFor: (item: T) => number | null,
|
||||
direction: SortDirection,
|
||||
) {
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((left, right) => {
|
||||
const leftValue = valueFor(left.item);
|
||||
const rightValue = valueFor(right.item);
|
||||
if (leftValue === null && rightValue === null) {
|
||||
return left.index - right.index;
|
||||
}
|
||||
if (leftValue === null) return 1;
|
||||
if (rightValue === null) return -1;
|
||||
const compared = leftValue - rightValue;
|
||||
return compared === 0
|
||||
? left.index - right.index
|
||||
: direction === "asc"
|
||||
? compared
|
||||
: -compared;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
222
lib/task-service.ts
Normal file
222
lib/task-service.ts
Normal 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
286
lib/user-auth.ts
Normal 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
30
package-lock.json
generated
@@ -8,11 +8,13 @@
|
||||
"name": "site-creator-vinext-starter",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "^2.0.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fflate": "0.7.4",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
"react-dom": "19.2.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
@@ -2123,6 +2125,31 @@
|
||||
"@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": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -11146,7 +11173,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "^2.0.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fflate": "0.7.4",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
"react-dom": "19.2.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
|
||||
38
tests/collection-schedule.test.mjs
Normal file
38
tests/collection-schedule.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isCollectionScheduleDue } from "../lib/collection-schedule.ts";
|
||||
import { sortWithNullsLast } from "../lib/sort-utils.ts";
|
||||
|
||||
test("runs the Beijing collection schedule from 09:00", () => {
|
||||
const scheduledDate = "2026-08-12";
|
||||
assert.equal(
|
||||
isCollectionScheduleDue(scheduledDate, Date.parse("2026-08-12T00:59:59Z")),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCollectionScheduleDue(scheduledDate, Date.parse("2026-08-12T01:00:00Z")),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCollectionScheduleDue(scheduledDate, Date.parse("2026-08-13T00:00:00Z")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("sorts both directions while keeping missing values last", () => {
|
||||
const values = [
|
||||
{ id: "missing-a", value: null },
|
||||
{ id: "middle", value: 20 },
|
||||
{ id: "high", value: 50 },
|
||||
{ id: "missing-b", value: null },
|
||||
{ id: "low", value: 10 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
sortWithNullsLast(values, (item) => item.value, "asc").map((item) => item.id),
|
||||
["low", "middle", "high", "missing-a", "missing-b"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortWithNullsLast(values, (item) => item.value, "desc").map((item) => item.id),
|
||||
["high", "middle", "low", "missing-a", "missing-b"],
|
||||
);
|
||||
});
|
||||
49
tests/distribution-release.test.mjs
Normal file
49
tests/distribution-release.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DistributionReleaseError,
|
||||
distributionReleaseBlockReason,
|
||||
} from "../lib/distribution-release-service.ts";
|
||||
|
||||
test("allows only unfinished assignments to return to the claim pool", () => {
|
||||
assert.equal(
|
||||
distributionReleaseBlockReason({ publishUrl: null, resultSubmittedAt: null }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
distributionReleaseBlockReason({
|
||||
publishUrl: "https://www.xiaohongshu.com/discovery/item/example",
|
||||
resultSubmittedAt: null,
|
||||
}),
|
||||
"已回填发布链接的笔记不能释放",
|
||||
);
|
||||
assert.equal(
|
||||
distributionReleaseBlockReason({
|
||||
publishUrl: null,
|
||||
resultSubmittedAt: "2026-08-12 09:00:00",
|
||||
}),
|
||||
"已提交结果截图的任务不能释放",
|
||||
);
|
||||
assert.equal(new DistributionReleaseError("blocked", 409).status, 409);
|
||||
});
|
||||
|
||||
test("releases a claim in one D1 batch and restores counters", async () => {
|
||||
const [service, actionRoute, adminApp] = await Promise.all([
|
||||
readFile(new URL("../lib/distribution-release-service.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(service, /await db\.batch\(statements\)/);
|
||||
assert.match(service, /DELETE FROM distributions WHERE id = \?/);
|
||||
assert.match(service, /UPDATE contents SET status = 'available'/);
|
||||
assert.match(service, /claimed_quantity = MAX\(claimed_quantity - 1, 0\)/);
|
||||
assert.match(service, /claimed_total = MAX\(claimed_total - 1, 0\)/);
|
||||
assert.match(service, /UPDATE claims/);
|
||||
assert.match(service, /UPDATE delegation_bundles/);
|
||||
assert.match(actionRoute, /body\.action === "release_distribution"/);
|
||||
assert.match(actionRoute, /isManagerRequest/);
|
||||
assert.match(adminApp, /确认释放这篇笔记/);
|
||||
assert.match(adminApp, /已释放,可重新领取/);
|
||||
assert.match(adminApp, /role="alertdialog"/);
|
||||
});
|
||||
78
tests/mcp-task-server.test.mjs
Normal file
78
tests/mcp-task-server.test.mjs
Normal 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"/);
|
||||
});
|
||||
@@ -50,3 +50,34 @@ test("creates an xlsx archive whose pictures are embedded in worksheet cells", (
|
||||
assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/);
|
||||
assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/);
|
||||
});
|
||||
|
||||
test("creates clickable external hyperlinks for resource exports", () => {
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: "KOC资源库",
|
||||
headers: ["账号名称", "账号主页"],
|
||||
columnWidths: [20, 40],
|
||||
rows: [
|
||||
{
|
||||
cells: ["小满的轻生活", "https://www.xiaohongshu.com/user/profile/test?x=1&y=2"],
|
||||
images: [],
|
||||
hyperlinks: [
|
||||
{
|
||||
column: 1,
|
||||
url: "https://www.xiaohongshu.com/user/profile/test?x=1&y=2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const archive = unzipSync(workbook);
|
||||
const sheet = strFromU8(archive["xl/worksheets/sheet1.xml"]);
|
||||
const relationships = strFromU8(
|
||||
archive["xl/worksheets/_rels/sheet1.xml.rels"],
|
||||
);
|
||||
|
||||
assert.match(sheet, /<hyperlink ref="B2" r:id="rId1"\/>/);
|
||||
assert.match(sheet, /<c r="B2" t="inlineStr" s="5">/);
|
||||
assert.match(relationships, /relationships\/hyperlink/);
|
||||
assert.match(relationships, /TargetMode="External"/);
|
||||
assert.match(relationships, /x=1&y=2/);
|
||||
});
|
||||
|
||||
@@ -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/layout.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(page, /requireChatGPTUser/);
|
||||
assert.match(page, /isAdminEmail/);
|
||||
assert.match(page, /getUserFromCookieHeader/);
|
||||
assert.match(page, /redirect\("\/login"\)/);
|
||||
assert.match(page, /currentUser=\{user\}/);
|
||||
assert.match(adminApp, /KOC LOOP/);
|
||||
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));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const [adminApp, layout, packageJson, hosting] = await Promise.all([
|
||||
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, /第1天到第7天/);
|
||||
assert.match(adminApp, /SortableRecoveryHeader/);
|
||||
assert.match(adminApp, /sortRecoveryDistributions/);
|
||||
assert.match(adminApp, /aria-sort=/);
|
||||
assert.match(adminApp, /direction === "asc" \? "↑" : "↓"/);
|
||||
assert.match(adminApp, /内容 \/ 发布账号/);
|
||||
assert.match(adminApp, /recovery-title-link/);
|
||||
assert.match(adminApp, /打开小红书笔记/);
|
||||
@@ -215,17 +237,18 @@ test("supports task collection schedules and latest public metrics", async () =>
|
||||
assert.match(collectionService, /runDueScheduledCollections/);
|
||||
assert.match(collectionService, /retryFailedCollections/);
|
||||
assert.match(collectionService, /exposure IS NOT NULL AND views IS NOT NULL/);
|
||||
assert.match(collectionService, /等待第\$\{scheduleDay\}天 10:00自动采集/);
|
||||
assert.match(collectionService, /等待第\$\{scheduleDay\}天 09:00自动采集/);
|
||||
assert.match(collectionService, /isCollectionScheduleDue/);
|
||||
assert.doesNotMatch(collectionService, /latestDueSchedule/);
|
||||
assert.match(collectionService, /自动追采/);
|
||||
assert.match(worker, /async scheduled/);
|
||||
assert.match(worker, /backfillAccountProfiles/);
|
||||
assert.match(bootstrapRoute, /backfillAccountProfiles/);
|
||||
assert.match(viteConfig, /"0 2 \* \* \*"/);
|
||||
assert.match(viteConfig, /"0 1 \* \* \*"/);
|
||||
assert.match(migration, /latest_likes/);
|
||||
assert.match(migration, /collection_runs_distribution_date_idx/);
|
||||
assert.match(accountMigration, /public_account_id/);
|
||||
assert.match(deployConfig, /"crons":\["0 2 \* \* \*"\]/);
|
||||
assert.match(deployConfig, /"crons":\["0 1 \* \* \*"\]/);
|
||||
});
|
||||
|
||||
test("exports complete task recovery data to Excel with embedded images", async () => {
|
||||
@@ -245,11 +268,92 @@ test("exports complete task recovery data to Excel with embedded images", async
|
||||
assert.match(exportRoute, /screenshot_key/);
|
||||
assert.match(exportRoute, /image_assets/);
|
||||
assert.match(exportRoute, /isAdminRequest/);
|
||||
assert.match(exportRoute, /consumeMcpExportToken/);
|
||||
assert.match(workbook, /xl\/drawings\/drawing1\.xml/);
|
||||
assert.match(workbook, /xl\/media\/image/);
|
||||
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 () => {
|
||||
const [adminApp, exportRoute, workbook] = await Promise.all([
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/resources-export/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/recovery-workbook.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(adminApp, /搜索账号名称 \/ 账号ID/);
|
||||
assert.match(adminApp, /搜索IP地区/);
|
||||
assert.match(adminApp, /搜索合作来源/);
|
||||
assert.match(adminApp, /ipLocation\.includes\(ipKeyword\)/);
|
||||
assert.match(adminApp, /includes\(sourceKeyword\)/);
|
||||
assert.match(adminApp, /导出筛选结果/);
|
||||
assert.match(adminApp, /filteredAccounts\.map\(\(item\) => item\.account\.id\)/);
|
||||
assert.match(exportRoute, /小红书号\/抖音号/);
|
||||
assert.match(exportRoute, /历史合作来源/);
|
||||
assert.match(exportRoute, /合作社资源 · 不可直联/);
|
||||
assert.match(exportRoute, /isManagerRequest/);
|
||||
assert.match(exportRoute, /consumeMcpExportToken/);
|
||||
assert.match(workbook, /relationships\/hyperlink/);
|
||||
});
|
||||
|
||||
test("supports anonymous partner delegation without creating a second data flow", async () => {
|
||||
const [
|
||||
adminApp,
|
||||
|
||||
@@ -14,8 +14,8 @@ const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||
const localBindingConfig = {
|
||||
main: "./worker/index.ts",
|
||||
compatibility_flags: ["nodejs_compat"],
|
||||
// Cloudflare cron uses UTC. 02:00 UTC is 10:00 in Asia/Shanghai.
|
||||
triggers: { crons: ["0 2 * * *"] },
|
||||
// Cloudflare cron uses UTC. 01:00 UTC is 09:00 in Asia/Shanghai.
|
||||
triggers: { crons: ["0 1 * * *"] },
|
||||
d1_databases: d1
|
||||
? [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user