Commit 53886bde authored by xuchentao's avatar xuchentao

feat: import Word articles with embedded images

parent ec14e5cc
Pipeline #435 passed with stage
in 16 seconds
...@@ -44,7 +44,22 @@ CMS_DATA_DIR=/root/jiayun/shared/cms-data ...@@ -44,7 +44,22 @@ CMS_DATA_DIR=/root/jiayun/shared/cms-data
CMS_BUILD_LOCK=/root/jiayun/shared/site-build.lock CMS_BUILD_LOCK=/root/jiayun/shared/site-build.lock
``` ```
生产环境必须修改示例密码和密钥。`CMS_API_KEY` 为可选的 Bearer API 访问密钥。 生产环境必须修改示例密码和密钥。`CMS_API_KEY` 为可选的 Bearer API 访问密钥。Word 导入接口只接受 API Key,不接受后台 Cookie 会话:
```http
POST /api/cms/imports/word
Authorization: Bearer <CMS_API_KEY>
Content-Type: application/json
{
"fileName": "文章.docx",
"category": "入行指南",
"title": "可选标题",
"dataUrl": "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,..."
}
```
接口仅支持 20MB 以内的 `.docx`,会提取 PNG/JPG/WEBP/GIF 图片,将首个一级标题作为标题(请求中的 `title` 优先),并创建未发布草稿。响应包含 `slug``publishStatus``imageCount` 和 Mammoth 转换警告 `warnings`
## SEO 与部署 ## SEO 与部署
......
This diff is collapsed.
...@@ -15,17 +15,24 @@ ...@@ -15,17 +15,24 @@
"check": "astro check", "check": "astro check",
"start": "node --env-file-if-exists=.env server.mjs", "start": "node --env-file-if-exists=.env server.mjs",
"admin": "npm start", "admin": "npm start",
"serve": "npm run build && npm start" "serve": "npm run build && npm start",
"test": "tsx --test test/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "9.5.5", "@astrojs/node": "9.5.5",
"@astrojs/sitemap": "^3.7.3", "@astrojs/sitemap": "^3.7.3",
"astro": "5.18.2", "astro": "5.18.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"marked": "^14.1.4" "mammoth": "^1.12.0",
"marked": "^14.1.4",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.6", "@astrojs/check": "^0.9.6",
"@types/turndown": "^5.0.6",
"docx": "^9.7.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }
import fs from "node:fs/promises";
import path from "node:path";
import {
AlignmentType,
Document,
HeadingLevel,
ImageRun,
Packer,
Paragraph,
TextRun,
} from "docx";
const root = process.cwd();
const outputDir = path.join(root, "test-artifacts");
const imagePath = "/Users/mac/Desktop/test.png";
const image = await fs.readFile(imagePath);
const date = new Date().toISOString().slice(0, 10).replaceAll("-", "");
const marker = `CMSUPLOADTEST${date}`;
const title = "天津新人跑网约车:从方案评估到上岗运营的四步指南";
const sections = [
{
heading: "第一步:先说清预算、时间与跑车目标",
paragraphs: [
"准备入行时,不要急着先选车。全职还是兼职、每天可投入的时间、现有预算与风险承受能力,都会影响租期、车型与平台选择。",
"嘉运网约车建议到店时带上自己的预算、时间安排和驾驶经验,先把用车方式与运营目标说清楚。",
],
},
{
heading: "第二步:同步规划车辆、证照与平台",
paragraphs: [
"网约车运营不只是租车或买车。车型是否符合平台准入、驾驶员证与车辆营运证如何办理,需要在签订长期合同前一起核对。",
"嘉运可提供新能源车辆销售、短租、月租、长租与以租代购等选择,并协助办理网约车驾驶员证和车辆营运证,对接曹操出行、滴滴、高德等主流平台。",
],
},
{
heading: "第三步:签约前核对总成本与合同责任",
paragraphs: [
"不要只看月租或月供。押金、保险、维保、违约责任、车辆残值与合同期限,都应列入总成本。所有价格、金融与租赁条件,应以门店当期政策和双方签署的正式合同为准。",
],
},
{
heading: "第四步:完成培训与持续运营",
paragraphs: [
"证照和平台注册完成后,新人还需要熟悉接单规则、日常车辆检查和成本记录。嘉运提供免费岗前培训,并由运营顾问跟进跑车过程中的账号、车辆与运营问题。",
"实际营收会受在线时长、平台规则与市场供需影响,应根据自己的运营数据持续复盘,不宜依赖固定收益承诺。",
],
},
];
const closing = "嘉运网约车是天津本地一站式网约车综合服务商,提供车辆租售、合规办证、平台入驻、司机培训与售后维保服务。咨询电话:022-87940507;门店地址:天津市南开区咸阳路45号小园新厦。";
await fs.mkdir(outputDir, { recursive: true });
await fs.copyFile(imagePath, path.join(outputDir, "test.png"));
const markdown = [
`# ${title}(Markdown 版)`,
"",
"准备在天津跑网约车,可以按照“沟通需求—匹配方案—办证入驻—培训上岗”的顺序推进。把车辆、证照、平台与运营放在同一个方案中考虑,更容易提前发现成本与合规问题。",
"",
"![Test image](./test.png)",
"",
...sections.flatMap((section) => [
`## ${section.heading}`,
"",
...section.paragraphs.flatMap((paragraph) => [paragraph, ""]),
]),
"## 到店前建议准备",
"",
"- 每天可投入的跑车时间",
"- 购车或租车的预算上限",
"- 现有驾驶证、车辆与网约车证照情况",
"- 计划入驻的平台和主要运营区域",
"",
closing,
"",
`内容校验标记:${marker}MD。`,
"",
].join("\n");
await fs.writeFile(path.join(outputDir, `cms-markdown-upload-test-${date}.md`), markdown);
const document = new Document({
sections: [{
properties: {
page: {
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
},
},
children: [
new Paragraph({
text: `${title}(Word 版)`,
heading: HeadingLevel.HEADING_1,
spacing: { before: 320, after: 160 },
}),
new Paragraph({
children: [new TextRun({ text: "准备在天津跑网约车,可以按照“沟通需求—匹配方案—办证入驻—培训上岗”的顺序推进。把车辆、证照、平台与运营放在同一个方案中考虑,更容易提前发现成本与合规问题。", font: "Arial Unicode MS" })],
spacing: { after: 120, line: 280 },
}),
new Paragraph({
children: [new ImageRun({ data: image, transformation: { width: 320, height: 210 }, type: "png" })],
alignment: AlignmentType.CENTER,
spacing: { before: 120 },
}),
...sections.flatMap((section) => [
new Paragraph({ text: section.heading, heading: HeadingLevel.HEADING_2 }),
...section.paragraphs.map((paragraph) => new Paragraph({ text: paragraph })),
]),
new Paragraph({ text: "到店前建议准备", heading: HeadingLevel.HEADING_2 }),
...[
"每天可投入的跑车时间",
"购车或租车的预算上限",
"现有驾驶证、车辆与网约车证照情况",
"计划入驻的平台和主要运营区域",
].map((text) => new Paragraph({ text, bullet: { level: 0 } })),
new Paragraph({ text: closing }),
new Paragraph({ text: `内容校验标记:${marker}DOCX。` }),
],
}],
styles: {
default: {
document: {
run: { font: "Calibri", size: 22 },
paragraph: { spacing: { after: 120, line: 264 } },
},
heading1: {
run: { font: "Calibri", size: 32, bold: true, color: "2E74B5" },
paragraph: { spacing: { before: 320, after: 160 }, outlineLevel: 0 },
},
heading2: {
run: { font: "Arial Unicode MS", size: 26, bold: true, color: "2E74B5" },
paragraph: { spacing: { before: 240, after: 100 }, outlineLevel: 1 },
},
},
},
});
await fs.writeFile(path.join(outputDir, `cms-word-import-test-${date}.docx`), await Packer.toBuffer(document));
console.log(JSON.stringify({ outputDir, date, marker }));
import fs from "node:fs/promises";
import path from "node:path";
const baseUrl = String(process.argv[2] || "http://101.126.10.129:8790").replace(/\/$/, "");
const apiKey = process.env.CMS_API_KEY || "";
const artifactsDir = path.join(process.cwd(), "test-artifacts");
if (!apiKey) throw new Error("请先在 .env 中配置 CMS_API_KEY");
const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
async function cms(route, options = {}) {
const response = await fetch(`${baseUrl}/api/cms${route}`, {
method: options.method || "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const payload = await response.json().catch(() => ({}));
if (response.status !== options.expectedStatus) {
throw new Error(`${route} 预期 ${options.expectedStatus},实际 ${response.status}${payload.error || "未知错误"}`);
}
return payload;
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const files = await fs.readdir(artifactsDir);
const markdownName = files.find((file) => /^cms-markdown-upload-test-\d+\.md$/.test(file));
const wordName = files.find((file) => /^cms-word-import-test-\d+\.docx$/.test(file));
assert(markdownName && wordName, "缺少 Markdown 或 Word 测试文件");
const image = await fs.readFile(path.join(artifactsDir, "test.png"));
const uploadedImage = await cms("/uploads", {
method: "POST",
expectedStatus: 200,
body: { dataUrl: `data:image/png;base64,${image.toString("base64")}` },
});
assert(/^\/uploads\/[a-zA-Z0-9._-]+$/.test(uploadedImage.url), "Markdown 图片上传地址无效");
const markdownSource = await fs.readFile(path.join(artifactsDir, markdownName), "utf8");
const markdownTitle = markdownSource.match(/^#\s+(.+)$/m)?.[1]?.trim();
const markdownBody = markdownSource
.replace(/^#\s+.+\n+/, "")
.replace("./test.png", uploadedImage.url)
.trim();
assert(markdownTitle && markdownBody.includes("CMSUPLOADTEST"), "Markdown 文件缺少标题或校验标记");
const markdownCreated = await cms("/articles", {
method: "POST",
expectedStatus: 201,
body: { title: markdownTitle, category: "入行指南", body: markdownBody },
});
assert(markdownCreated.publishStatus === "new-draft", "Markdown 文章不是新草稿");
const markdownRead = await cms(`/articles/${markdownCreated.slug}`, { expectedStatus: 200 });
assert(markdownRead.body.includes("CMSUPLOADTEST"), "Markdown 草稿回读缺少校验标记");
assert(markdownRead.body.includes(uploadedImage.url), "Markdown 草稿回读缺少图片地址");
const markdownImageResponse = await fetch(`${baseUrl}${uploadedImage.url}`);
assert(markdownImageResponse.status === 200, "Markdown 图片无法访问");
assert(markdownImageResponse.headers.get("content-type") === "image/png", "Markdown 图片 Content-Type 不是 image/png");
const word = await fs.readFile(path.join(artifactsDir, wordName));
const wordCreated = await cms("/imports/word", {
method: "POST",
expectedStatus: 201,
body: {
fileName: wordName,
category: "入行指南",
dataUrl: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${word.toString("base64")}`,
},
});
assert(wordCreated.publishStatus === "new-draft", "Word 文章不是新草稿");
assert(wordCreated.imageCount === 1, "Word 导入图片数量不是 1");
const wordRead = await cms(`/articles/${wordCreated.slug}`, { expectedStatus: 200 });
assert(wordRead.body.includes("CMSUPLOADTEST"), "Word 草稿回读缺少校验标记");
assert(!/^#\s+/m.test(wordRead.body), "Word 一级标题在 Markdown 正文中重复");
const wordImageUrl = wordRead.body.match(/!\[[^\]]*\]\((\/uploads\/[^)]+)\)/)?.[1];
assert(wordImageUrl, "Word 草稿缺少图片地址");
const wordImageResponse = await fetch(`${baseUrl}${wordImageUrl}`);
assert(wordImageResponse.status === 200, "Word 提取图片无法访问");
assert(wordImageResponse.headers.get("content-type") === "image/png", "Word 提取图片 Content-Type 不是 image/png");
const result = {
uploadedAt: new Date().toISOString(),
baseUrl,
markdown: { slug: markdownCreated.slug, title: markdownRead.title, imageUrl: uploadedImage.url },
word: { slug: wordCreated.slug, title: wordRead.title, imageUrl: wordImageUrl, warnings: wordCreated.warnings },
};
await fs.writeFile(path.join(artifactsDir, "upload-result.json"), `${JSON.stringify(result, null, 2)}\n`);
console.log(`CMS 上传验证通过:Markdown=${result.markdown.slug} Word=${result.word.slug}`);
...@@ -470,8 +470,22 @@ export async function saveUpload(dataUrl: unknown): Promise<string> { ...@@ -470,8 +470,22 @@ export async function saveUpload(dataUrl: unknown): Promise<string> {
const match = String(dataUrl || "").match(/^data:([^;]+);base64,(.+)$/s); const match = String(dataUrl || "").match(/^data:([^;]+);base64,(.+)$/s);
if (!match || !extensions[match[1]]) throw new StoreError("仅支持 PNG、JPG、WEBP 或 GIF 图片"); if (!match || !extensions[match[1]]) throw new StoreError("仅支持 PNG、JPG、WEBP 或 GIF 图片");
const buffer = Buffer.from(match[2], "base64"); const buffer = Buffer.from(match[2], "base64");
if (buffer.length > 8 * 1024 * 1024) throw new StoreError("图片不能超过 8MB", 413); if (buffer.length > 20 * 1024 * 1024) throw new StoreError("图片不能超过 20MB", 413);
const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${extensions[match[1]]}`; return saveUploadBuffer(buffer, match[1]);
}
export async function saveUploadBuffer(buffer: Buffer, contentType: string): Promise<string> {
await ensureStore();
const extensions: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif" };
const extension = extensions[contentType];
if (!extension) throw new StoreError("仅支持 PNG、JPG、WEBP 或 GIF 图片");
if (buffer.length > 20 * 1024 * 1024) throw new StoreError("图片不能超过 20MB", 413);
const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${extension}`;
await writeAtomic(path.join(UPLOADS_DIR, name), buffer); await writeAtomic(path.join(UPLOADS_DIR, name), buffer);
return `/uploads/${name}`; return `/uploads/${name}`;
} }
export async function removeUpload(url: string): Promise<void> {
const filename = url.match(/^\/uploads\/([a-zA-Z0-9._-]+)$/)?.[1];
if (filename) await fs.unlink(path.join(UPLOADS_DIR, filename)).catch(() => {});
}
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
writeArticle, writeArticle,
} from "./article-store"; } from "./article-store";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs"; import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
import { importWordArticle } from "./word-import";
const PASSWORD = process.env.CMS_PASSWORD || ""; const PASSWORD = process.env.CMS_PASSWORD || "";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex"); const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
...@@ -290,6 +291,11 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -290,6 +291,11 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
return json({ ok: true, url: await saveUpload(body.dataUrl) }); return json({ ok: true, url: await saveUpload(body.dataUrl) });
} }
if (route === "imports/word" && method === "POST") {
if (!apiKeyValid(request)) throw new StoreError("Word 导入需要有效的 CMS API Key", 401);
return json(await importWordArticle(await bodyOf(request)), 201);
}
return json({ error: "接口不存在" }, 404); return json({ error: "接口不存在" }, 404);
} catch (error) { } catch (error) {
if (error instanceof StoreError) return json({ error: error.message, ...error.details }, error.status); if (error instanceof StoreError) return json({ error: error.message, ...error.details }, error.status);
......
import mammoth from "mammoth";
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
import {
StoreError,
generateSlug,
getPublishStatus,
removeUpload,
saveUploadBuffer,
writeArticle,
} from "./article-store";
import { withSiteBuildLock } from "../../scripts/site-build.mjs";
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const MAX_FILE_BYTES = 20 * 1024 * 1024;
interface WordImportInput {
fileName?: unknown;
category?: unknown;
title?: unknown;
dataUrl?: unknown;
}
export interface WordImportResult {
ok: true;
slug: string;
title: string;
publishStatus: "new-draft" | "edited-draft" | "live";
imageCount: number;
warnings: string[];
}
function decodeDocx(input: WordImportInput): Buffer {
const fileName = String(input.fileName || "").trim();
if (!fileName.toLowerCase().endsWith(".docx")) {
throw new StoreError("仅支持 .docx Word 文件,不支持旧版 .doc 格式");
}
const match = String(input.dataUrl || "").match(/^data:([^;,]+);base64,([a-zA-Z0-9+/=\s]+)$/);
if (!match || match[1].toLowerCase() !== DOCX_MIME) {
throw new StoreError("Word 文件数据格式不正确");
}
const compactBase64 = match[2].replace(/\s/g, "");
if (!compactBase64 || compactBase64.length > Math.ceil(MAX_FILE_BYTES / 3) * 4 + 4) {
throw new StoreError("Word 文件不能超过 20MB", 413);
}
const buffer = Buffer.from(compactBase64, "base64");
if (buffer.length > MAX_FILE_BYTES) throw new StoreError("Word 文件不能超过 20MB", 413);
if (buffer.length < 4 || buffer.subarray(0, 2).toString("binary") !== "PK") {
throw new StoreError("Word 文件内容无效");
}
return buffer;
}
function titleFromHeading(html: string): { title: string; htmlWithoutHeading: string } {
const heading = html.match(/<h1(?:\s[^>]*)?>([\s\S]*?)<\/h1>/i);
if (!heading) return { title: "", htmlWithoutHeading: html };
const titleService = new TurndownService();
const title = titleService.turndown(heading[1]).replace(/\s+/g, " ").trim();
return {
title,
htmlWithoutHeading: `${html.slice(0, heading.index)}${html.slice((heading.index || 0) + heading[0].length)}`,
};
}
export async function importWordArticle(input: WordImportInput): Promise<WordImportResult> {
const buffer = decodeDocx(input);
const uploadedUrls: string[] = [];
try {
const converted = await mammoth.convertToHtml(
{ buffer },
{
convertImage: mammoth.images.imgElement(async (image) => {
const content = await image.readAsBuffer();
const url = await saveUploadBuffer(content, image.contentType.toLowerCase());
uploadedUrls.push(url);
return { src: url };
}),
},
);
const extracted = titleFromHeading(converted.value);
const title = String(input.title || "").replace(/\s+/g, " ").trim() || extracted.title;
if (!title) throw new StoreError("请在 Word 中添加一级标题,或在请求中提供 title");
const turndown = new TurndownService({ headingStyle: "atx", bulletListMarker: "-" });
turndown.use(gfm);
const body = turndown.turndown(extracted.htmlWithoutHeading).trim();
if (!body) throw new StoreError("Word 文件没有可导入的正文");
const { slug, article } = await withSiteBuildLock(async () => {
const slug = await generateSlug(input.category);
const article = await writeArticle(slug, { title, category: input.category, body }, true);
return { slug, article };
});
return {
ok: true,
slug,
title,
publishStatus: await getPublishStatus(article),
imageCount: uploadedUrls.length,
warnings: converted.messages.map((message) => message.message),
};
} catch (error) {
await Promise.all(uploadedUrls.map(removeUpload));
throw error;
}
}
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export const gfm: TurndownService.Plugin;
export const highlightedCodeBlock: TurndownService.Plugin;
export const strikethrough: TurndownService.Plugin;
export const tables: TurndownService.Plugin;
export const taskListItems: TurndownService.Plugin;
}
# 天津新人跑网约车:从方案评估到上岗运营的四步指南(Markdown 版)
准备在天津跑网约车,可以按照“沟通需求—匹配方案—办证入驻—培训上岗”的顺序推进。把车辆、证照、平台与运营放在同一个方案中考虑,更容易提前发现成本与合规问题。
![Test image](./test.png)
## 第一步:先说清预算、时间与跑车目标
准备入行时,不要急着先选车。全职还是兼职、每天可投入的时间、现有预算与风险承受能力,都会影响租期、车型与平台选择。
嘉运网约车建议到店时带上自己的预算、时间安排和驾驶经验,先把用车方式与运营目标说清楚。
## 第二步:同步规划车辆、证照与平台
网约车运营不只是租车或买车。车型是否符合平台准入、驾驶员证与车辆营运证如何办理,需要在签订长期合同前一起核对。
嘉运可提供新能源车辆销售、短租、月租、长租与以租代购等选择,并协助办理网约车驾驶员证和车辆营运证,对接曹操出行、滴滴、高德等主流平台。
## 第三步:签约前核对总成本与合同责任
不要只看月租或月供。押金、保险、维保、违约责任、车辆残值与合同期限,都应列入总成本。所有价格、金融与租赁条件,应以门店当期政策和双方签署的正式合同为准。
## 第四步:完成培训与持续运营
证照和平台注册完成后,新人还需要熟悉接单规则、日常车辆检查和成本记录。嘉运提供免费岗前培训,并由运营顾问跟进跑车过程中的账号、车辆与运营问题。
实际营收会受在线时长、平台规则与市场供需影响,应根据自己的运营数据持续复盘,不宜依赖固定收益承诺。
## 到店前建议准备
- 每天可投入的跑车时间
- 购车或租车的预算上限
- 现有驾驶证、车辆与网约车证照情况
- 计划入驻的平台和主要运营区域
嘉运网约车是天津本地一站式网约车综合服务商,提供车辆租售、合规办证、平台入驻、司机培训与售后维保服务。咨询电话:022-87940507;门店地址:天津市南开区咸阳路45号小园新厦。
内容校验标记:CMSUPLOADTEST20260731MD。
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Document, HeadingLevel, ImageRun, Packer, Paragraph } from "docx";
const API_KEY = "word-import-test-key";
const MARKER = `WORDIMPORTTEST${Date.now()}`;
const ONE_PIXEL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XPrlGQAAAABJRU5ErkJggg==",
"base64",
);
async function createDocx(): Promise<Buffer> {
const document = new Document({
sections: [{
children: [
new Paragraph({ text: "Word 导入集成测试", heading: HeadingLevel.HEADING_1 }),
new Paragraph(`这是唯一正文标记:${MARKER}`),
new Paragraph({ children: [new ImageRun({ data: ONE_PIXEL_PNG, transformation: { width: 1, height: 1 }, type: "png" })] }),
],
}],
});
return Packer.toBuffer(document);
}
test("Word 导入会建立带图片的未发布草稿", async () => {
const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), "jiayun-word-import-"));
process.env.CMS_DATA_DIR = dataRoot;
process.env.CMS_BUILD_LOCK = path.join(dataRoot, "site-build.lock");
process.env.CMS_API_KEY = API_KEY;
const [{ handleCmsApi }, { UPLOADS_DIR }, { GET: getUpload }] = await Promise.all([
import("../src/lib/cms-api"),
import("../src/lib/article-store"),
import("../src/pages/uploads/[file]"),
]);
const docx = await createDocx();
const payload = {
fileName: "集成测试.docx",
category: "入行指南",
dataUrl: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${docx.toString("base64")}`,
};
const request = (key: string, body = payload) => new Request("http://localhost/api/cms/imports/word", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const invalidKey = await handleCmsApi(request("wrong-key"), "imports/word");
assert.equal(invalidKey.status, 401);
const oldDoc = await handleCmsApi(request(API_KEY, { ...payload, fileName: "旧版.doc" }), "imports/word");
assert.equal(oldDoc.status, 400);
assert.match((await oldDoc.json()).error, /\.docx/);
const imported = await handleCmsApi(request(API_KEY), "imports/word");
assert.equal(imported.status, 201);
const result = await imported.json();
assert.equal(result.title, "Word 导入集成测试");
assert.equal(result.publishStatus, "new-draft");
assert.equal(result.imageCount, 1);
const articleResponse = await handleCmsApi(
new Request(`http://localhost/api/cms/articles/${result.slug}`, { headers: { Authorization: `Bearer ${API_KEY}` } }),
`articles/${result.slug}`,
);
assert.equal(articleResponse.status, 200);
const article = await articleResponse.json();
assert.match(article.body, new RegExp(MARKER));
assert.doesNotMatch(article.body, /^#\s+Word 导入集成测试/m);
const imageUrl = article.body.match(/!\[[^\]]*\]\((\/uploads\/[^)]+)\)/)?.[1];
assert.ok(imageUrl);
const imageName = path.basename(imageUrl);
const imageResponse = await getUpload({ params: { file: imageName } } as never);
assert.equal(imageResponse.status, 200);
assert.equal(imageResponse.headers.get("content-type"), "image/png");
assert.deepEqual(Buffer.from(await imageResponse.arrayBuffer()), ONE_PIXEL_PNG);
assert.equal((await fs.readdir(UPLOADS_DIR)).length, 1);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment