v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器

This commit is contained in:
bigcode
2026-11-04 06:10:04 +08:00
parent 2251a25e12
commit b16c33c08b
15 changed files with 2053 additions and 152 deletions
+512
View File
@@ -0,0 +1,512 @@
/**
* # MCP 服务器 DusunIoT CMS(stdio)
* 让 ZCode 等 Agent 直接管理站点内容:页面/文章 CRUD(支持 Elementor JSON
* 自定义单页面)、修订恢复、媒体上传、产品/订单/询盘查询与状态流转、看板统计。
*
* 协议:JSON-RPC 2.0 over stdio(MCP 2024-11-05),无 SDK 依赖,Bun 直跑:
* bun mcp/server.ts
* 认证:本地直连 PostgreSQL(与站点同库),仅限受信任的本机环境使用;
* 写操作全部落 audit_logs(actorId 为空表示 MCP 调用)。
*/
import { SQL } from "bun";
import { parseImageSize } from "../src/server/image-size";
/** # 成员变量 数据库连接(与站点同库;单连接足够 MCP 串行调用) **/
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 });
/** # 成员变量 MCP 工具定义(name/description/inputSchema) **/
const TOOLS = [
{
name: "dashboard_stats",
description: "站点运营看板统计:内容/产品/订单/询盘/媒体计数、新询盘数、待处理订单数、营收(按币种)、近 7 天询盘趋势",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "list_contents",
description: "内容列表(页面 page / 文章 post / 落地页 landing),支持状态与关键字过滤、分页;返回 id/slug/标题/状态/更新时间",
inputSchema: {
type: "object",
properties: {
type: { type: "string", enum: ["page", "post", "landing", "template"], description: "内容类型过滤" },
status: { type: "string", enum: ["draft", "review", "published"], description: "状态过滤" },
q: { type: "string", description: "标题/别名关键字" },
page: { type: "number", description: "页码,默认 1" },
pageSize: { type: "number", description: "每页条数,默认 20,上限 100" },
},
additionalProperties: false,
},
},
{
name: "get_content",
description: "读取单条内容全文:正文 HTML、Elementor JSON(meta.elementor)、SEO 字段、修订数",
inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"], additionalProperties: false },
},
{
name: "create_content",
description: "新建内容(自定义单页面推荐 type=page + elementorJson,前台 /slug/ 通栏渲染;纯 HTML 页用 html 字段)",
inputSchema: {
type: "object",
properties: {
type: { type: "string", enum: ["page", "post", "landing"], description: "内容类型" },
title: { type: "string", description: "标题(必填)" },
slug: { type: "string", description: "URL 别名,留空按标题生成" },
html: { type: "string", description: "正文 HTML" },
elementorJson: { type: "string", description: "Elementor 文档 JSON 字符串(存 meta.elementor,优先于 HTML 渲染)" },
excerpt: { type: "string" },
status: { type: "string", enum: ["draft", "review", "published"] },
seoTitle: { type: "string" },
seoDescription: { type: "string" },
},
required: ["type", "title"],
additionalProperties: false,
},
},
{
name: "update_content",
description: "更新内容(部分字段即可;elementorJson 传 null 表示清除 Elementor 数据)。更新前自动写修订快照",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
title: { type: "string" },
slug: { type: "string" },
html: { type: "string" },
elementorJson: { type: ["string", "null"], description: "Elementor JSON 字符串或 null(清除)" },
excerpt: { type: "string" },
status: { type: "string", enum: ["draft", "review", "published"] },
seoTitle: { type: ["string", "null"] },
seoDescription: { type: ["string", "null"] },
},
required: ["id"],
additionalProperties: false,
},
},
{
name: "delete_content",
description: "删除内容(物理删除)",
inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"], additionalProperties: false },
},
{
name: "list_revisions",
description: "内容修订快照列表(每次保存自动留档)",
inputSchema: { type: "object", properties: { contentId: { type: "number" } }, required: ["contentId"], additionalProperties: false },
},
{
name: "restore_revision",
description: "恢复内容到指定修订(当前版本先再留档)",
inputSchema: { type: "object", properties: { id: { type: "number", description: "修订 ID" } }, required: ["id"], additionalProperties: false },
},
{
name: "list_media",
description: "媒体库列表(路径/URL/宽高/大小),支持文件名关键字与分页",
inputSchema: {
type: "object",
properties: { q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "upload_media",
description: "上传媒体文件(base64),写入 uploads/YYYY/MM/ 并入库,自动解析图片宽高",
inputSchema: {
type: "object",
properties: {
filename: { type: "string" },
mime: { type: "string" },
dataBase64: { type: "string", description: "文件内容 base64(≤20MB)" },
},
required: ["filename", "dataBase64"],
additionalProperties: false,
},
},
{
name: "list_products",
description: "产品列表(SKU/标题/价格/库存/状态),支持关键字与分页",
inputSchema: {
type: "object",
properties: { q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "list_orders",
description: "订单列表(单号/状态/金额/客户名),支持状态过滤与分页",
inputSchema: {
type: "object",
properties: { status: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "update_order_status",
description: "更新订单状态(pending/on-hold/processing/completed/cancelled/refunded)",
inputSchema: {
type: "object",
properties: { id: { type: "number" }, status: { type: "string" } },
required: ["id", "status"],
additionalProperties: false,
},
},
{
name: "list_inquiries",
description: "询盘列表(姓名/邮箱/公司/国家/感兴趣产品/状态),支持状态过滤与分页;CRM 跟进用",
inputSchema: {
type: "object",
properties: { status: { type: "string", enum: ["new", "read", "replied", "spam", "archived"] }, q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "update_inquiry_status",
description: "更新询盘状态(跟进流转:new→read→replied,或标记 spam/archived)",
inputSchema: {
type: "object",
properties: { id: { type: "number" }, status: { type: "string", enum: ["new", "read", "replied", "spam", "archived"] } },
required: ["id", "status"],
additionalProperties: false,
},
},
{
name: "get_setting",
description: "读取系统设置键(site/woocommerce/mail/seo 等)",
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"], additionalProperties: false },
},
{
name: "set_setting",
description: "写入系统设置键(整键覆写,value 须为 JSON 对象)",
inputSchema: {
type: "object",
properties: { key: { type: "string" }, value: { type: "object" } },
required: ["key", "value"],
additionalProperties: false,
},
},
];
/** # 函数 内部工具:把标题转成 URL 别名(slug),与后台 admin.ts 同规则 */
function slugify(title: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 200) || `item-${Date.now()}`
);
}
/** # 函数 内部工具:写审计日志(MCP 调用 actorId 为空) */
async function audit(action: string, entity: string, entityId: string | number, meta?: unknown) {
await sql`insert into audit_logs (actor_id, action, entity, entity_id, meta) values (null, ${action}, ${entity}, ${String(entityId)}, ${sql.json(meta ?? {})}::jsonb)`;
}
/** # 函数 内部工具:把 SQL 片段用 " and " 连接(Bun SQL 无 sql.join/sql.raw,嵌套模板实现) */
function andJoin(conds: SQL[]): SQL {
return conds.reduce((acc, c) => (acc ? (sql`${acc} and ${c}` as SQL) : c));
}
/** # 函数 内部工具:把 SQL 片段用 ", " 连接(UPDATE SET 列表用) */
function commaJoin(chunks: SQL[]): SQL {
return chunks.reduce((acc, c) => (acc ? (sql`${acc}, ${c}` as SQL) : c));
}
/** # 函数 内部工具:slug 冲突时追加 -2/-3 …(contents_slug_type_idx 唯一约束) */
async function uniqueSlug(base: string, type: string): Promise<string> {
let slug = base;
for (let n = 1; n < 100; n++) {
const hit = await sql`select 1 from contents where slug = ${slug} and type = ${type} limit 1`;
if (hit.length === 0) return slug;
slug = `${base}-${n + 1}`;
}
throw new Error("slug 生成失败:冲突过多");
}
/** # 函数 工具调用实现(tools/call 分发)
* 参数 name:工具名;args:入参对象
* 返回值 任意可 JSON 序列化结果(由调用方包成 MCP text content)
*/
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
const a = args ?? {};
switch (name) {
case "dashboard_stats": {
const [counts] = await sql`
select
(select count(*) from contents) as contents,
(select count(*) from products) as products,
(select count(*) from orders) as orders,
(select count(*) from inquiries) as inquiries,
(select count(*) from media) as media,
(select count(*) from orders where status in ('pending','on-hold')) as pending_orders,
(select count(*) from inquiries where status = 'new') as new_inquiries`;
const revenue = await sql`select currency, coalesce(sum(total),0) as total from orders where status in ('completed','processing') group by currency`;
const trend = await sql`
select to_char(date_trunc('day', created_at), 'YYYY-MM-DD') as day, count(*) as n
from inquiries where created_at > now() - interval '7 days' group by 1 order by 1`;
return { counts, revenue, trend };
}
case "list_contents": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const conds: SQL[] = [];
if (a.type) conds.push(sql`type = ${a.type}`);
if (a.status) conds.push(sql`status = ${a.status}`);
if (a.q) conds.push(sql`(title ilike ${"%" + a.q + "%"} or slug ilike ${"%" + a.q + "%"})`);
const where = conds.length ? sql` where ${andJoin(conds)}` : sql``;
const items = await sql`select id, type, slug, title, status, template, views, published_at, updated_at from contents${where} order by updated_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from contents${where}`;
return { items, total: Number(total), page, pageSize };
}
case "get_content": {
const [row] = await sql`select * from contents where id = ${Number(a.id)}`;
if (!row) throw new Error(`内容 #${a.id} 不存在`);
const revs = await sql`select count(*) as n from content_revisions where content_id = ${Number(a.id)}`;
return { ...row, revisionCount: Number(revs[0].n) };
}
case "create_content": {
const type = String(a.type || "page");
const title = String(a.title || "").trim();
if (!title) throw new Error("title 必填");
const slug = await uniqueSlug(String(a.slug || "").trim() || slugify(title), type);
// elementorJson 解析进 meta.elementor(非法 JSON 直接报错,防脏数据入渲染器)
const meta: Record<string, unknown> = {};
if (typeof a.elementorJson === "string" && a.elementorJson.trim()) {
try {
meta.elementor = JSON.parse(a.elementorJson);
} catch {
throw new Error("elementorJson 不是合法 JSON");
}
}
const status = String(a.status || "draft");
const [row] = await sql`
insert into contents (type, slug, title, excerpt, html, status, template, seo_title, seo_description, meta, published_at, author_id)
values (${type}, ${slug}, ${title}, ${String(a.excerpt || "")}, ${String(a.html || "")}, ${status}, 'default',
${a.seoTitle ? String(a.seoTitle) : null}, ${a.seoDescription ? String(a.seoDescription) : null},
${sql.json(meta)}::jsonb, ${status === "published" ? new Date() : null}, null)
returning id`;
await audit("content.create", "content", row.id, { title, type, via: "mcp" });
return { ok: true, id: row.id, slug, frontUrl: status === "published" ? `/${slug}/` : null };
}
case "update_content": {
const id = Number(a.id);
const [cur] = await sql`select id, title, html, meta, status, published_at from contents where id = ${id}`;
if (!cur) throw new Error(`内容 #${id} 不存在`);
// 修订快照(与后台保存一致)
await sql`insert into content_revisions (content_id, title, html, author_id) values (${id}, ${cur.title}, ${cur.html}, null)`;
// 逐字段构建 SET(仅更新显式传入的字段)
const sets: SQL[] = [sql`updated_at = ${new Date()}`];
if (a.title !== undefined) sets.push(sql`title = ${String(a.title)}`);
if (a.slug !== undefined) sets.push(sql`slug = ${await uniqueSlug(String(a.slug), String(cur.type ?? "page"))}`);
if (a.html !== undefined) sets.push(sql`html = ${String(a.html)}`);
if (a.excerpt !== undefined) sets.push(sql`excerpt = ${String(a.excerpt)}`);
if (a.status !== undefined) {
const st = String(a.status);
sets.push(sql`status = ${st}`);
if (st === "published" && !cur.published_at) sets.push(sql`published_at = ${new Date()}`);
}
if (a.seoTitle !== undefined) sets.push(sql`seo_title = ${a.seoTitle === null ? null : String(a.seoTitle)}`);
if (a.seoDescription !== undefined) sets.push(sql`seo_description = ${a.seoDescription === null ? null : String(a.seoDescription)}`);
if (a.elementorJson !== undefined) {
const base = (cur.meta ?? {}) as Record<string, unknown>;
if (a.elementorJson === null) delete base.elementor;
else {
try {
base.elementor = JSON.parse(String(a.elementorJson));
} catch {
throw new Error("elementorJson 不是合法 JSON");
}
}
sets.push(sql`meta = ${sql.json(base)}::jsonb`);
}
await sql`update contents set ${commaJoin(sets)} where id = ${id}`;
await audit("content.update", "content", id, { fields: Object.keys(a).filter((k) => k !== "id"), via: "mcp" });
return { ok: true, id };
}
case "delete_content": {
const id = Number(a.id);
await sql`delete from content_terms where content_id = ${id}`;
await sql`delete from content_revisions where content_id = ${id}`;
await sql`delete from contents where id = ${id}`;
await audit("content.delete", "content", id, { via: "mcp" });
return { ok: true };
}
case "list_revisions":
return await sql`select id, content_id, title, author_id, created_at from content_revisions where content_id = ${Number(a.contentId)} order by id desc limit 50`;
case "restore_revision": {
const [rev] = await sql`select * from content_revisions where id = ${Number(a.id)}`;
if (!rev) throw new Error(`修订 #${a.id} 不存在`);
const [cur] = await sql`select title, html from contents where id = ${rev.content_id}`;
if (cur) await sql`insert into content_revisions (content_id, title, html, author_id) values (${rev.content_id}, ${cur.title}, ${cur.html}, null)`;
await sql`update contents set title = ${rev.title}, html = ${rev.html}, updated_at = ${new Date()} where id = ${rev.content_id}`;
await audit("content.revision.restore", "content", rev.content_id, { revisionId: rev.id, via: "mcp" });
return { ok: true, contentId: rev.content_id };
}
case "list_media": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const where = a.q ? sql` where path ilike ${"%" + a.q + "%"}` : sql``;
const items = await sql`select id, path, url, mime_type, width, height, size, alt_text, created_at from media${where} order by id desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from media${where}`;
return { items, total: Number(total), page, pageSize };
}
case "upload_media": {
const buf = Buffer.from(String(a.dataBase64 || ""), "base64");
if (!buf.length) throw new Error("空文件");
if (buf.length > 20 * 1024 * 1024) throw new Error("文件超过 20MB 上限");
const now = new Date();
const dir = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}`;
const clean = String(a.filename || `file-${Date.now()}`).replace(/[\\/]+/g, "-").replace(/[^a-zA-Z0-9._\-\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 160) || `file-${Date.now()}`;
const dot = clean.lastIndexOf(".");
const stem = dot === -1 ? clean : clean.slice(0, dot);
const ext = dot === -1 ? "" : clean.slice(dot).toLowerCase();
const fs = await import("node:fs/promises");
const path = await import("node:path");
const root = process.env.DUSUN_ROOT || path.resolve(import.meta.dir, "..");
const absDir = path.join(root, "public", "uploads", dir);
await fs.mkdir(absDir, { recursive: true });
let rel = `${dir}/${stem}${ext}`;
for (let n = 1; n < 1000; n++) {
try {
await fs.access(path.join(root, "public", "uploads", rel));
rel = `${dir}/${stem}-${n}${ext}`;
} catch {
break;
}
}
await fs.writeFile(path.join(root, "public", "uploads", rel), buf);
const dims = parseImageSize(new Uint8Array(buf.slice(0, Math.min(buf.length, 1024 * 1024))));
const [row] = await sql`
insert into media (path, url, mime_type, width, height, size)
values (${rel}, ${"/uploads/" + rel}, ${String(a.mime || "application/octet-stream")},
${dims?.[0] ?? null}, ${dims?.[1] ?? null}, ${buf.length})
returning id`;
await audit("media.upload", "media", row.id, { path: rel, via: "mcp" });
return { ok: true, id: row.id, url: `/uploads/${rel}`, width: dims?.[0] ?? null, height: dims?.[1] ?? null };
}
case "list_products": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const where = a.q ? sql` where (title ilike ${"%" + a.q + "%"} or sku ilike ${"%" + a.q + "%"} or slug ilike ${"%" + a.q + "%"})` : sql``;
const items = await sql`select id, sku, slug, title, price, regular_price, stock, stock_status, status, featured, updated_at from products${where} order by updated_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from products${where}`;
return { items, total: Number(total), page, pageSize };
}
case "list_orders": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const where = a.status ? sql` where status = ${a.status}` : sql``;
const items = await sql`select id, order_number, status, currency, total, payment_status, billing, created_at from orders${where} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from orders${where}`;
return { items, total: Number(total), page, pageSize };
}
case "update_order_status": {
const id = Number(a.id);
const status = String(a.status);
if (!["pending", "on-hold", "processing", "completed", "cancelled", "refunded"].includes(status)) throw new Error("非法订单状态");
await sql`update orders set status = ${status}, updated_at = ${new Date()} where id = ${id}`;
await audit("order.status", "order", id, { status, via: "mcp" });
return { ok: true };
}
case "list_inquiries": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const conds: SQL[] = [];
if (a.status) conds.push(sql`status = ${a.status}`);
if (a.q) conds.push(sql`(name ilike ${"%" + a.q + "%"} or email ilike ${"%" + a.q + "%"} or company ilike ${"%" + a.q + "%"})`);
const where = conds.length ? sql` where ${andJoin(conds)}` : sql``;
const items = await sql`select id, name, email, company, country, product_interest, status, created_at from inquiries${where} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from inquiries${where}`;
return { items, total: Number(total), page, pageSize };
}
case "update_inquiry_status": {
const id = Number(a.id);
const status = String(a.status);
if (!["new", "read", "replied", "spam", "archived"].includes(status)) throw new Error("非法询盘状态");
await sql`update inquiries set status = ${status} where id = ${id}`;
await audit("inquiry.status", "inquiry", id, { status, via: "mcp" });
return { ok: true };
}
case "get_setting": {
const [row] = await sql`select key, value, updated_at from settings where key = ${String(a.key)}`;
return row ?? null;
}
case "set_setting": {
const key = String(a.key);
if (!/^[a-z0-9_-]{1,128}$/i.test(key)) throw new Error("非法设置键名");
await sql`insert into settings (key, value, updated_at) values (${key}, ${sql.json(a.value)}::jsonb, ${new Date()})
on conflict (key) do update set value = ${sql.json(a.value)}::jsonb, updated_at = ${new Date()}`;
await audit("settings.update", "settings", key, { via: "mcp" });
return { ok: true };
}
default:
throw new Error(`未知工具:${name}`);
}
}
/* ============================= MCP stdio 协议层 ============================= */
/** # 函数 写一帧 JSON-RPC 响应到 stdout */
function send(msg: Record<string, unknown>) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
/** # 类型 已初始化标记(initialize 之前拒绝 tools/* 调用) **/
let initialized = false;
/** # 函数 逐行解析 stdin 请求并分发(initialize / tools/list / tools/call / ping) */
for await (const chunk of Bun.stdin.stream()) {
const text = new TextDecoder().decode(chunk);
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let req: { id?: unknown; method?: string; params?: Record<string, unknown> };
try {
req = JSON.parse(line);
} catch {
continue;
}
const id = req.id;
try {
switch (req.method) {
case "initialize":
send({
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "dusuniot-cms", version: process.env.npm_package_version ?? "0.11.0" },
},
});
break;
case "notifications/initialized":
initialized = true;
break;
case "ping":
send({ jsonrpc: "2.0", id, result: {} });
break;
case "tools/list":
send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
break;
case "tools/call": {
if (!initialized) throw new Error("server not initialized");
const name = String((req.params as { name?: string }).name);
const args = ((req.params as { arguments?: Record<string, unknown> }).arguments ?? {}) as Record<string, unknown>;
try {
const result = await callTool(name, args);
send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } });
} catch (e) {
// 工具级错误按 isError 返回(协议要求不中断会话)
send({
jsonrpc: "2.0",
id,
result: { content: [{ type: "text", text: String(e instanceof Error ? e.message : e) }], isError: true },
});
}
break;
}
default:
if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${req.method}` } });
}
} catch (e) {
if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e instanceof Error ? e.message : e) } });
}
}
}