Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
3212 lines
113 KiB
JavaScript
3212 lines
113 KiB
JavaScript
import { createServer } from 'node:http'
|
||
import { randomUUID } from 'node:crypto'
|
||
|
||
const PORT = Number(process.env.PLAYWRIGHT_MOCK_PORT || 5159)
|
||
const FRONTEND_ORIGIN =
|
||
process.env.PLAYWRIGHT_FRONTEND_ORIGIN || 'http://127.0.0.1:4321'
|
||
const ADMIN_ORIGIN =
|
||
process.env.PLAYWRIGHT_ADMIN_ORIGIN || 'http://127.0.0.1:4322'
|
||
const MOCK_ORIGIN = `http://127.0.0.1:${PORT}`
|
||
const SESSION_COOKIE = 'termi_admin_session'
|
||
const SESSION_VALUE = 'mock-admin-session'
|
||
const CONTENT_TYPES = {
|
||
json: 'application/json; charset=utf-8',
|
||
text: 'text/plain; charset=utf-8',
|
||
sse: 'text/event-stream; charset=utf-8',
|
||
svg: 'image/svg+xml; charset=utf-8',
|
||
}
|
||
const VALID_LOGIN = {
|
||
username: 'admin',
|
||
password: 'admin123',
|
||
}
|
||
const BASE_TS = Date.parse('2026-04-01T09:00:00.000Z')
|
||
|
||
const categoryCatalog = [
|
||
{
|
||
name: '前端工程',
|
||
slug: 'frontend-engineering',
|
||
description: '围绕 Astro、Svelte、设计系统与终端风格交互的实现记录。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/category-frontend.svg`,
|
||
accent_color: '#2563eb',
|
||
seo_title: '前端工程专题',
|
||
seo_description: 'Astro / Svelte / 终端风格 UI 相关的实现与复盘。',
|
||
},
|
||
{
|
||
name: '测试体系',
|
||
slug: 'testing-systems',
|
||
description: '记录 Playwright、CI、回归测试与稳定性治理的落地经验。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/category-testing.svg`,
|
||
accent_color: '#14b8a6',
|
||
seo_title: '测试体系专题',
|
||
seo_description: '端到端回归、CI 编排和测试工具链整理。',
|
||
},
|
||
{
|
||
name: '运维值班',
|
||
slug: 'operations-oncall',
|
||
description: '备份、恢复、对象存储与上线清单等值班经验。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/category-ops.svg`,
|
||
accent_color: '#f97316',
|
||
seo_title: '运维值班专题',
|
||
seo_description: '备份、恢复、发布和值班操作的实践手册。',
|
||
},
|
||
]
|
||
|
||
const tagCatalog = [
|
||
{
|
||
name: 'Astro',
|
||
slug: 'astro',
|
||
description: 'Astro 内容站与 SSR 落地笔记。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/tag-astro.svg`,
|
||
accent_color: '#8b5cf6',
|
||
seo_title: 'Astro 标签页',
|
||
seo_description: 'Astro、组件、内容站工程实践。',
|
||
},
|
||
{
|
||
name: 'Playwright',
|
||
slug: 'playwright',
|
||
description: 'E2E 回归与浏览器自动化。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/tag-playwright.svg`,
|
||
accent_color: '#10b981',
|
||
seo_title: 'Playwright 标签页',
|
||
seo_description: 'Playwright、回归测试与自动化流程。',
|
||
},
|
||
{
|
||
name: 'Svelte',
|
||
slug: 'svelte',
|
||
description: 'Svelte 状态管理、组件组织与交互。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/tag-svelte.svg`,
|
||
accent_color: '#f97316',
|
||
seo_title: 'Svelte 标签页',
|
||
seo_description: 'Svelte 页面状态和组件模式记录。',
|
||
},
|
||
{
|
||
name: 'AI',
|
||
slug: 'ai',
|
||
description: '站内问答、提示词和索引配置实验。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/tag-ai.svg`,
|
||
accent_color: '#ec4899',
|
||
seo_title: 'AI 标签页',
|
||
seo_description: 'AI 问答、提示词和检索增强内容。',
|
||
},
|
||
{
|
||
name: 'Docker',
|
||
slug: 'docker',
|
||
description: 'Docker 编排、发布和值班脚本。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/tag-docker.svg`,
|
||
accent_color: '#0ea5e9',
|
||
seo_title: 'Docker 标签页',
|
||
seo_description: 'Docker 发布、运行时与值班记录。',
|
||
},
|
||
{
|
||
name: 'CI',
|
||
slug: 'ci',
|
||
description: 'CI 与工作流编排。',
|
||
cover_image: `${MOCK_ORIGIN}/media-files/tag-ci.svg`,
|
||
accent_color: '#22c55e',
|
||
seo_title: 'CI 标签页',
|
||
seo_description: 'CI、工作流和自动化执行策略。',
|
||
},
|
||
]
|
||
|
||
const postCatalog = [
|
||
{
|
||
title: 'Astro 终端博客信息架构实战',
|
||
slug: 'astro-terminal-blog',
|
||
description: 'Termi 前台首页、过滤器、文章卡片和终端风格布局的整体拆解。',
|
||
category: '前端工程',
|
||
tags: ['Astro', 'Svelte', 'AI'],
|
||
post_type: 'article',
|
||
image: '/review-covers/the-long-season.svg',
|
||
images: ['/review-covers/black-myth-wukong.svg', '/review-covers/thirteen-invites.svg'],
|
||
pinned: true,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2026-03-28T09:00:00.000Z',
|
||
updated_at: '2026-03-29T06:00:00.000Z',
|
||
paragraphs: [
|
||
'Termi 的首页不是纯展示页,而是把文章、评测、友链和订阅动作压进同一个终端式入口,确保用户第一次进入时就能感知站点的信息密度。',
|
||
'为了让首页筛选足够顺滑,我们把文章类型、分类和标签都收敛到统一的状态模型里,任何切换都只改 URL 与最小可见集,而不是整页重新渲染。',
|
||
'这一版改造也顺手补了阅读指标入口,让热门内容、完读率和阅读时长能在前台自然暴露,方便后续继续做推荐与 AI 问答。',
|
||
],
|
||
},
|
||
{
|
||
title: 'Playwright 回归工作流设计',
|
||
slug: 'playwright-regression-workflow',
|
||
description: 'Termi 前后台共用一套 Playwright smoke 回归,覆盖 CI、mock server 和关键路径。',
|
||
category: '测试体系',
|
||
tags: ['Playwright', 'CI', 'Astro'],
|
||
post_type: 'article',
|
||
image: '/review-covers/placed-within.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2026-03-20T10:30:00.000Z',
|
||
updated_at: '2026-03-20T10:30:00.000Z',
|
||
paragraphs: [
|
||
'Playwright 套件会同时拉起前台、后台和 mock server,用独立的内存状态来模拟文章、评论、友链、评测和订阅流程。',
|
||
'这样做的好处是 CI 不再依赖真实 Rust 后端或数据库,也能把前后台的大部分关键交互跑一遍。',
|
||
'只要 mock 数据结构和接口契约稳定,这套 smoke 就能快速捕获组件改版、表单失效和路由错误。',
|
||
],
|
||
},
|
||
{
|
||
title: 'Svelte 状态仓库拆分模式',
|
||
slug: 'svelte-state-patterns',
|
||
description: 'Termi 在前台筛选、搜索和订阅弹窗里如何拆分 Svelte 状态与副作用。',
|
||
category: '前端工程',
|
||
tags: ['Svelte', 'Astro', 'Playwright'],
|
||
post_type: 'article',
|
||
image: '/review-covers/journey-to-the-west-editorial.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2026-02-18T08:10:00.000Z',
|
||
updated_at: '2026-02-18T08:10:00.000Z',
|
||
paragraphs: [
|
||
'搜索模式切换、首页过滤器和订阅弹窗本质上都属于“短生命周期但高交互密度”的状态,适合被拆成局部仓库而不是抬到全局。',
|
||
'这样每个功能块都能保留独立的 URL 同步和本地缓存逻辑,避免一个页面动作把整个前台状态带乱。',
|
||
],
|
||
},
|
||
{
|
||
title: 'AI 搜索提示词调优记录',
|
||
slug: 'ai-search-prompt-design',
|
||
description: 'Termi 站内 AI 问答如何平衡检索结果、回答密度和提示词约束。',
|
||
category: '测试体系',
|
||
tags: ['AI', 'Playwright', 'CI'],
|
||
post_type: 'article',
|
||
image: '/review-covers/hero-dreams-in-tired-life.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2025-12-11T08:00:00.000Z',
|
||
updated_at: '2025-12-11T08:00:00.000Z',
|
||
paragraphs: [
|
||
'AI 问答页的目标不是单纯返回一段答案,而是把引用来源、索引时间和相关度一起暴露给用户。',
|
||
'我们更关心“这个回答是否足够可追溯”,因此来源卡片、SSE 状态提示和缓存命中都被纳入同一条链路。',
|
||
],
|
||
},
|
||
{
|
||
title: 'Docker 发布值班清单',
|
||
slug: 'docker-rollout-checklist',
|
||
description: 'Termi 的 CI/CD 与 Docker 发布手册:构建、上传、回滚和对象存储连通性检查。',
|
||
category: '运维值班',
|
||
tags: ['Docker', 'CI', 'AI'],
|
||
post_type: 'article',
|
||
image: '/review-covers/black-myth-wukong.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2025-10-02T07:20:00.000Z',
|
||
updated_at: '2025-10-02T07:20:00.000Z',
|
||
paragraphs: [
|
||
'值班手册的核心是把部署前检查、发布后验证和回滚策略写清楚,而不是只留下一个 docker compose up。',
|
||
'在 Termi 里,R2 连通性、备份导出和前后台 smoke 都属于上线前必须执行的低成本检查。',
|
||
],
|
||
},
|
||
{
|
||
title: '终端风格内容站封面系统',
|
||
slug: 'terminal-cover-system',
|
||
description: 'Termi 如何统一文章封面、评测海报和后台媒体库,减少重复素材治理成本。',
|
||
category: '前端工程',
|
||
tags: ['Astro', 'AI', 'Docker'],
|
||
post_type: 'article',
|
||
image: '/review-covers/the-long-season.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2025-07-15T11:00:00.000Z',
|
||
updated_at: '2025-07-16T04:00:00.000Z',
|
||
paragraphs: [
|
||
'无论是文章页 hero 图、评测封面还是后台素材库,最终都需要同一套 key、alt、caption 和标签模型。',
|
||
'这让媒体库不仅能服务前台渲染,也能成为后台创建文章和评测时的统一素材来源。',
|
||
],
|
||
},
|
||
{
|
||
title: '内容观测与阅读完成度',
|
||
slug: 'content-observability-lab',
|
||
description: 'Termi 的阅读进度、页面访问与完读事件如何反哺首页热门内容和后台分析页。',
|
||
category: '测试体系',
|
||
tags: ['AI', 'Playwright', 'Svelte'],
|
||
post_type: 'article',
|
||
image: '/review-covers/placed-within.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2025-04-04T06:40:00.000Z',
|
||
updated_at: '2025-04-05T09:20:00.000Z',
|
||
paragraphs: [
|
||
'阅读进度上报看似只是埋点,但如果没有统一会话标识和节流策略,后台分析很快就会被噪音淹没。',
|
||
'Termi 的做法是把 page view、read progress 和 read complete 统一成同一个内容分析模型。',
|
||
],
|
||
},
|
||
{
|
||
title: 'Markdown 导入与版本回滚',
|
||
slug: 'markdown-import-and-revisions',
|
||
description: 'Termi 后台的 Markdown 导入、版本快照和回滚流程,方便内容大批量迁移。',
|
||
category: '运维值班',
|
||
tags: ['CI', 'Docker', 'Playwright'],
|
||
post_type: 'article',
|
||
image: '/review-covers/thirteen-invites.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2024-11-21T10:00:00.000Z',
|
||
updated_at: '2024-11-21T10:00:00.000Z',
|
||
paragraphs: [
|
||
'批量导入 Markdown 时最容易漏掉的是 slug 冲突、元数据缺失和回滚策略,因此版本快照必须和写入动作绑定。',
|
||
'只要恢复接口能按 full、markdown、metadata 三种模式工作,回归测试就能覆盖大部分内容变更风险。',
|
||
],
|
||
},
|
||
{
|
||
title: '前台订阅弹窗节奏实验',
|
||
slug: 'subscription-popup-experiments',
|
||
description: 'Termi 订阅弹窗如何控制触发时机、收口文案和确认链路,避免一上来就打断阅读。',
|
||
category: '测试体系',
|
||
tags: ['AI', 'Svelte', 'Astro'],
|
||
post_type: 'article',
|
||
image: '/review-covers/journey-to-the-west-editorial.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2024-08-08T12:00:00.000Z',
|
||
updated_at: '2024-08-08T12:00:00.000Z',
|
||
paragraphs: [
|
||
'订阅弹窗如果没有上下文,就会被用户当成纯打断;因此我们只在滚动达到阈值或明确点击订阅按钮后再打开。',
|
||
'邮箱确认页、偏好管理页和退订页也被纳入同一条测试链,确保状态切换能闭环。',
|
||
],
|
||
},
|
||
{
|
||
title: '友链申请审核设计稿',
|
||
slug: 'friend-link-review-workflow',
|
||
description: 'Termi 友链从前台申请到后台审核的最小闭环,以及分类、状态与前台展示规则。',
|
||
category: '前端工程',
|
||
tags: ['Astro', 'Playwright', 'Svelte'],
|
||
post_type: 'article',
|
||
image: '/review-covers/hero-dreams-in-tired-life.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2024-06-13T13:20:00.000Z',
|
||
updated_at: '2024-06-13T13:20:00.000Z',
|
||
paragraphs: [
|
||
'友链系统看起来简单,但实际上同时涉及前台表单、后台审核、状态变更和分组展示。',
|
||
'把这条链路纳入 Playwright 回归后,能很快发现表单字段映射和审核按钮失效的问题。',
|
||
],
|
||
},
|
||
{
|
||
title: '值班手册:备份与恢复演练',
|
||
slug: 'ops-backup-runbook',
|
||
description: 'Termi 备份导出、导入、媒体清单和恢复演练的操作要点。',
|
||
category: '运维值班',
|
||
tags: ['Docker', 'AI', 'CI'],
|
||
post_type: 'tweet',
|
||
image: '/review-covers/black-myth-wukong.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'published',
|
||
visibility: 'public',
|
||
created_at: '2024-03-03T09:10:00.000Z',
|
||
updated_at: '2024-03-03T09:10:00.000Z',
|
||
paragraphs: [
|
||
'备份不是把 JSON 导出来就结束,恢复演练必须能证明分类、标签、评测、媒体与 Markdown 都能被重新装回。',
|
||
'我们把这篇值班手册作为 tweet 类型内容保留,用来验证前台类型过滤与后台导出逻辑。',
|
||
],
|
||
},
|
||
{
|
||
title: '私有草稿:季度回顾',
|
||
slug: 'private-quarterly-retrospective',
|
||
description: '仅后台可见的私有草稿,用于验证预览、保存和独立工作台。',
|
||
category: '测试体系',
|
||
tags: ['AI', 'Playwright'],
|
||
post_type: 'article',
|
||
image: '/review-covers/the-long-season.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'draft',
|
||
visibility: 'private',
|
||
created_at: '2026-03-30T11:00:00.000Z',
|
||
updated_at: '2026-03-30T11:00:00.000Z',
|
||
paragraphs: [
|
||
'这是一个私有草稿,只应出现在后台编辑器与独立预览窗口中。',
|
||
'用它可以验证 include_private、preview 和 Markdown 更新链路。',
|
||
],
|
||
},
|
||
{
|
||
title: '计划中的站点改版路线图',
|
||
slug: 'scheduled-site-roadmap',
|
||
description: '一篇已经排期但尚未到发布时间的路线图文章,用于校验后台状态统计。',
|
||
category: '测试体系',
|
||
tags: ['CI', 'Astro'],
|
||
post_type: 'article',
|
||
image: '/review-covers/placed-within.svg',
|
||
images: [],
|
||
pinned: false,
|
||
status: 'scheduled',
|
||
visibility: 'public',
|
||
publish_at: '2026-04-15T03:00:00.000Z',
|
||
unpublish_at: null,
|
||
created_at: '2026-04-01T02:00:00.000Z',
|
||
updated_at: '2026-04-01T02:00:00.000Z',
|
||
paragraphs: [
|
||
'这篇文章用于测试定时发布和后台概览统计,不应该出现在前台公开列表里。',
|
||
'当发布时间未到时,文章仍可在后台独立预览与对比。',
|
||
],
|
||
},
|
||
]
|
||
|
||
function iso(offsetMinutes = 0) {
|
||
return new Date(BASE_TS + offsetMinutes * 60_000).toISOString()
|
||
}
|
||
|
||
function json(res, status, payload, headers = {}) {
|
||
res.writeHead(status, {
|
||
'content-type': CONTENT_TYPES.json,
|
||
'cache-control': 'no-store',
|
||
...headers,
|
||
})
|
||
res.end(JSON.stringify(payload))
|
||
}
|
||
|
||
function text(res, status, body, headers = {}) {
|
||
res.writeHead(status, {
|
||
'content-type': CONTENT_TYPES.text,
|
||
'cache-control': 'no-store',
|
||
...headers,
|
||
})
|
||
res.end(body)
|
||
}
|
||
|
||
function reflectCors(req, res) {
|
||
const origin = req.headers.origin
|
||
if (!origin) {
|
||
return
|
||
}
|
||
|
||
if ([FRONTEND_ORIGIN, ADMIN_ORIGIN].includes(origin)) {
|
||
res.setHeader('access-control-allow-origin', origin)
|
||
res.setHeader('vary', 'Origin')
|
||
res.setHeader('access-control-allow-credentials', 'true')
|
||
res.setHeader(
|
||
'access-control-allow-headers',
|
||
'Content-Type, Authorization, X-Requested-With',
|
||
)
|
||
res.setHeader(
|
||
'access-control-allow-methods',
|
||
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||
)
|
||
}
|
||
}
|
||
|
||
function readRequestBody(req) {
|
||
return new Promise((resolve, reject) => {
|
||
const chunks = []
|
||
req.on('data', (chunk) => chunks.push(chunk))
|
||
req.on('end', () => resolve(Buffer.concat(chunks)))
|
||
req.on('error', reject)
|
||
})
|
||
}
|
||
|
||
function safeJsonParse(raw, fallback = {}) {
|
||
try {
|
||
return JSON.parse(raw)
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
function parseMultipartBody(buffer, contentType) {
|
||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
|
||
const boundary = boundaryMatch?.[1] || boundaryMatch?.[2]
|
||
if (!boundary) {
|
||
return { fields: {}, files: [] }
|
||
}
|
||
|
||
const source = buffer.toString('latin1')
|
||
const parts = source.split(`--${boundary}`)
|
||
const fields = {}
|
||
const files = []
|
||
|
||
for (const part of parts) {
|
||
const trimmed = part.trim()
|
||
if (!trimmed || trimmed === '--') {
|
||
continue
|
||
}
|
||
|
||
const [rawHeaders, ...bodyChunks] = part.split('\r\n\r\n')
|
||
if (!rawHeaders || !bodyChunks.length) {
|
||
continue
|
||
}
|
||
|
||
const body = bodyChunks.join('\r\n\r\n').replace(/\r\n$/, '')
|
||
const headerLines = rawHeaders
|
||
.split('\r\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
const disposition = headerLines.find((line) =>
|
||
line.toLowerCase().startsWith('content-disposition:'),
|
||
)
|
||
|
||
if (!disposition) {
|
||
continue
|
||
}
|
||
|
||
const nameMatch = disposition.match(/name="([^"]+)"/i)
|
||
const filenameMatch = disposition.match(/filename="([^"]*)"/i)
|
||
const fieldName = nameMatch?.[1]
|
||
|
||
if (!fieldName) {
|
||
continue
|
||
}
|
||
|
||
const contentTypeLine = headerLines.find((line) =>
|
||
line.toLowerCase().startsWith('content-type:'),
|
||
)
|
||
const fileName = filenameMatch?.[1]
|
||
|
||
if (fileName !== undefined) {
|
||
files.push({
|
||
fieldName,
|
||
filename: fileName,
|
||
contentType: contentTypeLine?.split(':')[1]?.trim() || 'application/octet-stream',
|
||
size: Buffer.from(body, 'latin1').length,
|
||
text: Buffer.from(body, 'latin1').toString('utf8'),
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (fieldName in fields) {
|
||
const current = fields[fieldName]
|
||
if (Array.isArray(current)) {
|
||
current.push(body)
|
||
} else {
|
||
fields[fieldName] = [current, body]
|
||
}
|
||
} else {
|
||
fields[fieldName] = body
|
||
}
|
||
}
|
||
|
||
return { fields, files }
|
||
}
|
||
|
||
async function parseRequest(req) {
|
||
const body = await readRequestBody(req)
|
||
const contentType = String(req.headers['content-type'] || '')
|
||
|
||
if (!body.length) {
|
||
return { body, json: {}, fields: {}, files: [] }
|
||
}
|
||
|
||
if (contentType.includes('application/json')) {
|
||
return { body, json: safeJsonParse(body.toString('utf8'), {}), fields: {}, files: [] }
|
||
}
|
||
|
||
if (contentType.includes('multipart/form-data')) {
|
||
const parsed = parseMultipartBody(body, contentType)
|
||
return { body, json: {}, ...parsed }
|
||
}
|
||
|
||
return { body, json: {}, fields: {}, files: [] }
|
||
}
|
||
|
||
function getCookies(req) {
|
||
return String(req.headers.cookie || '')
|
||
.split(';')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.reduce((acc, item) => {
|
||
const index = item.indexOf('=')
|
||
if (index === -1) {
|
||
return acc
|
||
}
|
||
acc[item.slice(0, index)] = item.slice(index + 1)
|
||
return acc
|
||
}, {})
|
||
}
|
||
|
||
function isAuthenticated(req) {
|
||
return getCookies(req)[SESSION_COOKIE] === SESSION_VALUE
|
||
}
|
||
|
||
function ensureAdmin(req, res) {
|
||
if (isAuthenticated(req)) {
|
||
return true
|
||
}
|
||
|
||
json(res, 401, {
|
||
error: 'unauthorized',
|
||
description: '当前未登录后台。',
|
||
})
|
||
return false
|
||
}
|
||
|
||
function slugify(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
}
|
||
|
||
function normalizeText(value) {
|
||
return String(value || '').trim()
|
||
}
|
||
|
||
function toBoolean(value, fallback = false) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return fallback
|
||
}
|
||
if (typeof value === 'boolean') {
|
||
return value
|
||
}
|
||
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase())
|
||
}
|
||
|
||
function parseJsonArray(value) {
|
||
if (Array.isArray(value)) {
|
||
return value
|
||
}
|
||
|
||
if (!value) {
|
||
return []
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
return Array.isArray(parsed) ? parsed : []
|
||
} catch {
|
||
return value
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
function buildMarkdown(title, paragraphs) {
|
||
return `# ${title}\n\n${paragraphs.join('\n\n')}\n`
|
||
}
|
||
|
||
function normalizeParagraphText(textValue) {
|
||
return textValue.replace(/\s+/g, ' ').trim().toLowerCase()
|
||
}
|
||
|
||
function fnv1aHash(value) {
|
||
let hash = 0x811c9dc5
|
||
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
hash ^= value.charCodeAt(index)
|
||
hash = Math.imul(hash, 0x01000193)
|
||
}
|
||
|
||
return (hash >>> 0).toString(16).padStart(8, '0')
|
||
}
|
||
|
||
function extractParagraphDescriptors(markdown) {
|
||
const occurrences = new Map()
|
||
const content = String(markdown || '')
|
||
.replace(/^#\s+.+\n+/, '')
|
||
.split(/\n{2,}/)
|
||
.map((block) => block.trim())
|
||
.filter((block) => block && !block.startsWith('#') && !block.startsWith('!['))
|
||
|
||
return content.map((paragraph) => {
|
||
const normalized = normalizeParagraphText(paragraph)
|
||
const hash = fnv1aHash(normalized)
|
||
const occurrence = (occurrences.get(hash) || 0) + 1
|
||
occurrences.set(hash, occurrence)
|
||
|
||
return {
|
||
key: `p-${hash}-${occurrence}`,
|
||
excerpt: paragraph.length <= 120 ? paragraph : `${paragraph.slice(0, 120).trimEnd()}...`,
|
||
text: paragraph,
|
||
}
|
||
})
|
||
}
|
||
|
||
function clone(value) {
|
||
return JSON.parse(JSON.stringify(value))
|
||
}
|
||
|
||
function createSessionResponse(authenticated) {
|
||
return {
|
||
authenticated,
|
||
username: authenticated ? VALID_LOGIN.username : null,
|
||
email: authenticated ? 'admin@termi.test' : null,
|
||
auth_source: authenticated ? 'mock-local' : null,
|
||
auth_provider: authenticated ? 'mock-session' : null,
|
||
groups: authenticated ? ['admin'] : [],
|
||
proxy_auth_enabled: false,
|
||
local_login_enabled: true,
|
||
can_logout: authenticated,
|
||
}
|
||
}
|
||
|
||
const NOW_ISO = iso(0)
|
||
|
||
const reviewCatalog = [
|
||
{
|
||
title: '《漫长的季节》',
|
||
review_type: 'anime',
|
||
rating: 5,
|
||
review_date: '2026-03-21',
|
||
status: 'completed',
|
||
description: '把东北工业景观、家庭裂痕和悬疑节奏揉进一起,后劲非常强。',
|
||
tags: ['悬疑', '现实主义', '年度最佳'],
|
||
cover: '/review-covers/the-long-season.svg',
|
||
link_url: 'https://example.invalid/reviews/the-long-season',
|
||
},
|
||
{
|
||
title: '《黑神话:悟空》',
|
||
review_type: 'game',
|
||
rating: 4,
|
||
review_date: '2026-03-18',
|
||
status: 'completed',
|
||
description: '美术和动作系统都足够能打,流程细节还有继续打磨空间。',
|
||
tags: ['动作', '国产', '年度观察'],
|
||
cover: '/review-covers/black-myth-wukong.svg',
|
||
link_url: 'https://example.invalid/reviews/black-myth-wukong',
|
||
},
|
||
{
|
||
title: '《置身事内》',
|
||
review_type: 'book',
|
||
rating: 5,
|
||
review_date: '2026-02-02',
|
||
status: 'completed',
|
||
description: '适合把财政、土地与地方治理如何互相牵引一次性理顺。',
|
||
tags: ['财政', '中国', '社会观察'],
|
||
cover: '/review-covers/placed-within.svg',
|
||
link_url: '/articles/playwright-regression-workflow',
|
||
},
|
||
{
|
||
title: '《宇宙探索编辑部》',
|
||
review_type: 'movie',
|
||
rating: 4,
|
||
review_date: '2026-01-12',
|
||
status: 'in-progress',
|
||
description: '荒诞感和理想主义都很讨喜,适合放进终端风格站点做海报实验。',
|
||
tags: ['电影', '公路片', '荒诞'],
|
||
cover: '/review-covers/journey-to-the-west-editorial.svg',
|
||
link_url: '',
|
||
},
|
||
]
|
||
|
||
const friendLinkCatalog = [
|
||
{
|
||
site_name: 'InitCool Docs',
|
||
site_url: 'https://docs.init.cool',
|
||
description: '收纳部署、值班与研发工具链的工程手册站。',
|
||
category: 'tech',
|
||
status: 'approved',
|
||
},
|
||
{
|
||
site_name: 'Svelte Terminal Lab',
|
||
site_url: 'https://svelte-terminal.example',
|
||
description: '记录 Svelte、Astro 与终端式交互实验。',
|
||
category: 'design',
|
||
status: 'approved',
|
||
},
|
||
{
|
||
site_name: 'Pending Link Review',
|
||
site_url: 'https://pending-link.example',
|
||
description: '待后台审核的新友链申请。',
|
||
category: 'other',
|
||
status: 'pending',
|
||
},
|
||
]
|
||
|
||
const mediaCatalog = [
|
||
{
|
||
key: 'covers/the-long-season.svg',
|
||
title: '漫长的季节封面',
|
||
alt_text: '终端风格的蓝色封面海报',
|
||
caption: '首页推荐位封面',
|
||
tags: ['cover', 'anime'],
|
||
notes: '默认 mock 素材库',
|
||
size_bytes: 1824,
|
||
},
|
||
{
|
||
key: 'posts/playwright-workflow.svg',
|
||
title: 'Playwright 工作流配图',
|
||
alt_text: '回归测试流程图',
|
||
caption: 'CI 与 smoke server 关系图',
|
||
tags: ['diagram', 'playwright'],
|
||
notes: '用于文章配图',
|
||
size_bytes: 2048,
|
||
},
|
||
]
|
||
|
||
function createSiteSettings() {
|
||
return {
|
||
id: 1,
|
||
site_name: 'InitCool',
|
||
site_short_name: 'Termi',
|
||
site_url: FRONTEND_ORIGIN,
|
||
site_title: 'InitCool - 终端风格的内容平台',
|
||
site_description: '一个基于终端美学的个人内容站,记录代码、设计和生活。',
|
||
hero_title: '欢迎来到我的极客终端博客',
|
||
hero_subtitle: '这里记录技术、代码和内容运营的持续迭代。',
|
||
owner_name: 'InitCool',
|
||
owner_title: 'Rust / Go / Frontend Builder',
|
||
owner_bio: '偏好用最小系统把内容、测试、值班和自动化串起来。',
|
||
owner_avatar_url: null,
|
||
social_github: 'https://github.com/initcool',
|
||
social_twitter: '',
|
||
social_email: 'mailto:initcool@example.com',
|
||
location: 'Hong Kong',
|
||
tech_stack: ['Astro', 'Svelte', 'React', 'Rust', 'Playwright', 'Docker'],
|
||
music_playlist: [
|
||
{
|
||
title: '山中来信',
|
||
artist: 'InitCool Radio',
|
||
album: '站点默认歌单',
|
||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||
cover_image_url: `${MOCK_ORIGIN}/media-files/playlist-1.svg`,
|
||
accent_color: '#2f6b5f',
|
||
description: '适合文章阅读时循环播放的轻氛围曲。',
|
||
},
|
||
],
|
||
ai_enabled: true,
|
||
paragraph_comments_enabled: true,
|
||
comment_verification_mode: 'captcha',
|
||
comment_turnstile_enabled: false,
|
||
subscription_verification_mode: 'off',
|
||
subscription_turnstile_enabled: false,
|
||
web_push_enabled: false,
|
||
turnstile_site_key: null,
|
||
turnstile_secret_key: null,
|
||
web_push_vapid_public_key: null,
|
||
web_push_vapid_private_key: null,
|
||
web_push_vapid_subject: null,
|
||
ai_provider: 'mock-openai',
|
||
ai_api_base: 'https://api.mock.invalid/v1',
|
||
ai_api_key: 'mock-key',
|
||
ai_chat_model: 'gpt-mock-4.1',
|
||
ai_image_provider: 'mock-images',
|
||
ai_image_api_base: 'https://images.mock.invalid/v1',
|
||
ai_image_api_key: 'mock-image-key',
|
||
ai_image_model: 'mock-image-1',
|
||
ai_providers: [
|
||
{
|
||
id: 'default',
|
||
name: 'Mock Provider',
|
||
provider: 'openai',
|
||
api_base: 'https://api.mock.invalid/v1',
|
||
api_key: 'mock-key',
|
||
chat_model: 'gpt-mock-4.1',
|
||
image_model: 'mock-image-1',
|
||
},
|
||
],
|
||
ai_active_provider_id: 'default',
|
||
ai_embedding_model: 'text-embedding-mock-3',
|
||
ai_system_prompt: '你是 Termi 的 mock AI 助手。',
|
||
ai_top_k: 6,
|
||
ai_chunk_size: 800,
|
||
ai_last_indexed_at: iso(-90),
|
||
ai_chunks_count: 128,
|
||
ai_local_embedding: 'disabled',
|
||
media_storage_provider: 'mock-r2',
|
||
media_r2_account_id: 'mock-account',
|
||
media_r2_bucket: 'termi-playwright',
|
||
media_r2_public_base_url: `${MOCK_ORIGIN}/media`,
|
||
media_r2_access_key_id: 'mock-access-key',
|
||
media_r2_secret_access_key: 'mock-secret',
|
||
seo_default_og_image: `${MOCK_ORIGIN}/media-files/default-og.svg`,
|
||
seo_default_twitter_handle: '@initcool',
|
||
notification_webhook_url: 'https://notify.mock.invalid/termi',
|
||
notification_channel_type: 'webhook',
|
||
notification_comment_enabled: true,
|
||
notification_friend_link_enabled: true,
|
||
subscription_popup_enabled: true,
|
||
subscription_popup_title: '订阅更新',
|
||
subscription_popup_description: '有新文章、周报和友链通知时,通过邮件第一时间收到提醒。',
|
||
subscription_popup_delay_seconds: 2,
|
||
search_synonyms: ['playwright,e2e,regression', 'ai,ask,assistant'],
|
||
}
|
||
}
|
||
|
||
function parseHeadingAndDescription(markdown, fallbackTitle = 'Untitled') {
|
||
const normalized = String(markdown || '').replace(/\r\n/g, '\n').trim()
|
||
const titleMatch = normalized.match(/^#\s+(.+)$/m)
|
||
const bodyText = normalized
|
||
.replace(/^#\s+.+\n+/, '')
|
||
.split(/\n{2,}/)
|
||
.map((item) => item.trim())
|
||
.find(Boolean)
|
||
|
||
return {
|
||
title: normalizeText(titleMatch?.[1] || fallbackTitle) || fallbackTitle,
|
||
description: normalizeText(bodyText || ''),
|
||
}
|
||
}
|
||
|
||
function makePostRecord(entry, id) {
|
||
const markdown = buildMarkdown(entry.title, entry.paragraphs)
|
||
const { title, description } = parseHeadingAndDescription(markdown, entry.title)
|
||
return {
|
||
id,
|
||
title,
|
||
slug: entry.slug,
|
||
description,
|
||
content: markdown,
|
||
category: entry.category,
|
||
tags: [...entry.tags],
|
||
post_type: entry.post_type,
|
||
image: entry.image || null,
|
||
images: entry.images?.length ? [...entry.images] : [],
|
||
pinned: Boolean(entry.pinned),
|
||
status: entry.status || 'published',
|
||
visibility: entry.visibility || 'public',
|
||
publish_at: entry.publish_at || null,
|
||
unpublish_at: entry.unpublish_at || null,
|
||
canonical_url: null,
|
||
noindex: false,
|
||
og_image: null,
|
||
redirect_from: [],
|
||
redirect_to: null,
|
||
created_at: entry.created_at || NOW_ISO,
|
||
updated_at: entry.updated_at || entry.created_at || NOW_ISO,
|
||
}
|
||
}
|
||
|
||
function buildReviewRecord(entry, id) {
|
||
return {
|
||
id,
|
||
title: entry.title,
|
||
review_type: entry.review_type,
|
||
rating: entry.rating,
|
||
review_date: entry.review_date,
|
||
status: entry.status,
|
||
description: entry.description,
|
||
tags: JSON.stringify(entry.tags || []),
|
||
cover: entry.cover || '',
|
||
link_url: entry.link_url || null,
|
||
created_at: iso(-id * 15),
|
||
updated_at: iso(-id * 10),
|
||
}
|
||
}
|
||
|
||
function createSubscriptionRecord(id, overrides = {}) {
|
||
return {
|
||
created_at: iso(-id * 6),
|
||
updated_at: iso(-id * 5),
|
||
id,
|
||
channel_type: 'email',
|
||
target: `user${id}@example.com`,
|
||
display_name: `订阅用户 ${id}`,
|
||
status: 'active',
|
||
filters: {
|
||
event_types: ['post.published', 'digest.weekly'],
|
||
categories: ['前端工程'],
|
||
tags: ['Playwright'],
|
||
},
|
||
metadata: {
|
||
source: 'seed',
|
||
},
|
||
secret: null,
|
||
notes: 'seed subscription',
|
||
confirm_token: `confirm-token-${id}`,
|
||
manage_token: `manage-token-${id}`,
|
||
verified_at: iso(-id * 4),
|
||
last_notified_at: iso(-id * 2),
|
||
failure_count: 0,
|
||
last_delivery_status: 'delivered',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function createMediaRecord(item, index) {
|
||
const url = `${MOCK_ORIGIN}/media/${encodeURIComponent(item.key)}`
|
||
return {
|
||
key: item.key,
|
||
url,
|
||
size_bytes: item.size_bytes,
|
||
last_modified: iso(-index * 7),
|
||
title: item.title,
|
||
alt_text: item.alt_text,
|
||
caption: item.caption,
|
||
tags: [...item.tags],
|
||
notes: item.notes,
|
||
body: `mock media body for ${item.key}`,
|
||
content_type: item.key.endsWith('.svg') ? CONTENT_TYPES.svg : CONTENT_TYPES.text,
|
||
}
|
||
}
|
||
|
||
function createInitialState() {
|
||
const site_settings = createSiteSettings()
|
||
const categories = categoryCatalog.map((item, index) => ({
|
||
id: index + 1,
|
||
name: item.name,
|
||
slug: item.slug,
|
||
count: 0,
|
||
description: item.description,
|
||
cover_image: item.cover_image,
|
||
accent_color: item.accent_color,
|
||
seo_title: item.seo_title,
|
||
seo_description: item.seo_description,
|
||
created_at: iso(-(index + 1) * 20),
|
||
updated_at: iso(-(index + 1) * 10),
|
||
}))
|
||
const tags = tagCatalog.map((item, index) => ({
|
||
id: index + 1,
|
||
name: item.name,
|
||
slug: item.slug,
|
||
count: 0,
|
||
description: item.description,
|
||
cover_image: item.cover_image,
|
||
accent_color: item.accent_color,
|
||
seo_title: item.seo_title,
|
||
seo_description: item.seo_description,
|
||
created_at: iso(-(index + 1) * 18),
|
||
updated_at: iso(-(index + 1) * 8),
|
||
}))
|
||
const posts = postCatalog.map((item, index) => makePostRecord(item, index + 1))
|
||
const reviews = reviewCatalog.map((item, index) => buildReviewRecord(item, index + 1))
|
||
const friend_links = friendLinkCatalog.map((item, index) => ({
|
||
id: index + 1,
|
||
site_name: item.site_name,
|
||
site_url: item.site_url,
|
||
avatar_url: null,
|
||
description: item.description,
|
||
category: item.category,
|
||
status: item.status,
|
||
created_at: iso(-(index + 1) * 12),
|
||
updated_at: iso(-(index + 1) * 6),
|
||
}))
|
||
const subscriptions = [
|
||
createSubscriptionRecord(1, {
|
||
target: 'watcher@example.com',
|
||
display_name: '产品订阅',
|
||
filters: {
|
||
event_types: ['post.published', 'digest.weekly'],
|
||
categories: ['测试体系'],
|
||
tags: ['Playwright', 'CI'],
|
||
},
|
||
}),
|
||
createSubscriptionRecord(2, {
|
||
target: 'ops@example.com',
|
||
display_name: '值班通知',
|
||
filters: {
|
||
event_types: ['digest.monthly', 'friend_link.created'],
|
||
categories: ['运维值班'],
|
||
tags: ['Docker'],
|
||
},
|
||
}),
|
||
]
|
||
const media = mediaCatalog.map((item, index) => createMediaRecord(item, index + 1))
|
||
const articlePost = posts.find((item) => item.slug === 'astro-terminal-blog') || posts[0]
|
||
const paragraph = extractParagraphDescriptors(articlePost.content)[0]
|
||
const comments = [
|
||
{
|
||
id: 1,
|
||
post_id: String(articlePost.id),
|
||
post_slug: articlePost.slug,
|
||
author: 'Alice',
|
||
email: 'alice@example.com',
|
||
avatar: null,
|
||
ip_address: '10.0.0.8',
|
||
user_agent: 'MockBrowser/1.0',
|
||
referer: `${FRONTEND_ORIGIN}/articles/${articlePost.slug}`,
|
||
content: '首页筛选和终端风格结合得很好,想看更多实现细节。',
|
||
reply_to: null,
|
||
reply_to_comment_id: null,
|
||
scope: 'article',
|
||
paragraph_key: null,
|
||
paragraph_excerpt: null,
|
||
approved: true,
|
||
created_at: iso(-40),
|
||
updated_at: iso(-40),
|
||
},
|
||
{
|
||
id: 2,
|
||
post_id: String(articlePost.id),
|
||
post_slug: articlePost.slug,
|
||
author: 'Bob',
|
||
email: 'bob@example.com',
|
||
avatar: null,
|
||
ip_address: '10.0.0.9',
|
||
user_agent: 'MockBrowser/1.0',
|
||
referer: `${FRONTEND_ORIGIN}/articles/${articlePost.slug}`,
|
||
content: '这里的 URL 同步策略很实用。',
|
||
reply_to: null,
|
||
reply_to_comment_id: null,
|
||
scope: 'paragraph',
|
||
paragraph_key: paragraph?.key || null,
|
||
paragraph_excerpt: paragraph?.excerpt || null,
|
||
approved: true,
|
||
created_at: iso(-35),
|
||
updated_at: iso(-35),
|
||
},
|
||
{
|
||
id: 3,
|
||
post_id: String(articlePost.id),
|
||
post_slug: articlePost.slug,
|
||
author: 'Carol',
|
||
email: 'carol@example.com',
|
||
avatar: null,
|
||
ip_address: '10.0.0.10',
|
||
user_agent: 'MockBrowser/1.0',
|
||
referer: `${FRONTEND_ORIGIN}/articles/${articlePost.slug}`,
|
||
content: '这条评论默认待审核,用来验证后台审核流程。',
|
||
reply_to: null,
|
||
reply_to_comment_id: null,
|
||
scope: 'article',
|
||
paragraph_key: null,
|
||
paragraph_excerpt: null,
|
||
approved: false,
|
||
created_at: iso(-25),
|
||
updated_at: iso(-25),
|
||
},
|
||
]
|
||
|
||
return {
|
||
site_settings,
|
||
categories,
|
||
tags,
|
||
posts,
|
||
reviews,
|
||
friend_links,
|
||
subscriptions,
|
||
media,
|
||
comments,
|
||
audit_logs: [],
|
||
post_revisions: [],
|
||
comment_blacklist: [
|
||
{
|
||
id: 1,
|
||
matcher_type: 'email',
|
||
matcher_value: 'blocked@example.com',
|
||
reason: 'seed rule',
|
||
active: true,
|
||
expires_at: null,
|
||
created_at: iso(-12),
|
||
updated_at: iso(-12),
|
||
effective: true,
|
||
},
|
||
],
|
||
comment_persona_logs: [],
|
||
deliveries: [],
|
||
ai_events: [],
|
||
content_events: [],
|
||
captcha_tokens: new Map(),
|
||
next_ids: {
|
||
post: posts.length + 1,
|
||
category: categories.length + 1,
|
||
tag: tags.length + 1,
|
||
comment: comments.length + 1,
|
||
friend_link: friend_links.length + 1,
|
||
review: reviews.length + 1,
|
||
subscription: subscriptions.length + 1,
|
||
audit: 1,
|
||
revision: 1,
|
||
delivery: 1,
|
||
blacklist: 2,
|
||
persona_log: 1,
|
||
ai_event: 1,
|
||
},
|
||
}
|
||
}
|
||
|
||
let state = createInitialState()
|
||
|
||
function nextId(bucket) {
|
||
const value = state.next_ids[bucket]
|
||
state.next_ids[bucket] += 1
|
||
return value
|
||
}
|
||
|
||
function isPostVisible(post, options = {}) {
|
||
const includePrivate = toBoolean(options.include_private, false)
|
||
const preview = toBoolean(options.preview, false)
|
||
const includeRedirects = toBoolean(options.include_redirects, false)
|
||
const now = BASE_TS
|
||
|
||
if (!post) return false
|
||
if (!includeRedirects && post.redirect_to) return false
|
||
if (preview) return true
|
||
if (post.status !== 'published') return false
|
||
if (post.visibility === 'private' && !includePrivate) return false
|
||
if (post.visibility === 'private') return false
|
||
if (post.publish_at && Date.parse(post.publish_at) > now) return false
|
||
if (post.unpublish_at && Date.parse(post.unpublish_at) <= now) return false
|
||
return true
|
||
}
|
||
|
||
function recalculateTaxonomyCounts() {
|
||
const categoryCounts = new Map()
|
||
const tagCounts = new Map()
|
||
|
||
for (const post of state.posts.filter((item) => isPostVisible(item))) {
|
||
if (post.category) {
|
||
categoryCounts.set(post.category, (categoryCounts.get(post.category) || 0) + 1)
|
||
}
|
||
for (const tag of post.tags || []) {
|
||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)
|
||
}
|
||
}
|
||
|
||
for (const category of state.categories) {
|
||
category.count = categoryCounts.get(category.name) || 0
|
||
}
|
||
for (const tag of state.tags) {
|
||
tag.count = tagCounts.get(tag.name) || 0
|
||
}
|
||
}
|
||
|
||
function addAuditLog(action, target_type, target_label, target_id = null, metadata = null) {
|
||
state.audit_logs.unshift({
|
||
id: nextId('audit'),
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
actor_username: VALID_LOGIN.username,
|
||
actor_email: 'admin@termi.test',
|
||
actor_source: 'mock-admin',
|
||
action,
|
||
target_type,
|
||
target_id: target_id === null ? null : String(target_id),
|
||
target_label: target_label || null,
|
||
metadata,
|
||
})
|
||
}
|
||
|
||
function addPostRevision(post, operation = 'update', reason = null) {
|
||
state.post_revisions.unshift({
|
||
id: nextId('revision'),
|
||
post_slug: post.slug,
|
||
post_title: post.title,
|
||
operation,
|
||
revision_reason: reason,
|
||
actor_username: VALID_LOGIN.username,
|
||
actor_email: 'admin@termi.test',
|
||
actor_source: 'mock-admin',
|
||
created_at: iso(-1),
|
||
has_markdown: true,
|
||
metadata: { category: post.category, tags: post.tags, status: post.status },
|
||
markdown: post.content,
|
||
snapshot: clone(post),
|
||
})
|
||
}
|
||
|
||
function createNotificationDelivery({
|
||
subscription,
|
||
eventType,
|
||
status = 'queued',
|
||
responseText = 'queued by mock server',
|
||
payload = null,
|
||
}) {
|
||
return {
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
id: nextId('delivery'),
|
||
subscription_id: subscription?.id ?? null,
|
||
channel_type: subscription?.channel_type ?? 'email',
|
||
target: subscription?.target ?? 'unknown@example.com',
|
||
event_type: eventType,
|
||
status,
|
||
provider: 'mock-delivery',
|
||
response_text: responseText,
|
||
payload,
|
||
attempts_count: status === 'queued' ? 0 : 1,
|
||
next_retry_at: status === 'retry_pending' ? iso(10) : null,
|
||
last_attempt_at: status === 'queued' ? null : iso(-1),
|
||
delivered_at: status === 'sent' ? iso(-1) : null,
|
||
}
|
||
}
|
||
|
||
function sanitizeFilename(value, fallback = 'upload.bin') {
|
||
const normalized = String(value || '').split(/[\\/]/).pop() || fallback
|
||
return normalized.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || fallback
|
||
}
|
||
|
||
function makeMediaRecordFromUpload(key, file) {
|
||
return {
|
||
key,
|
||
url: `${MOCK_ORIGIN}/media/${encodeURIComponent(key)}`,
|
||
size_bytes: file?.size ?? Buffer.byteLength(file?.body || ''),
|
||
last_modified: iso(-1),
|
||
title: sanitizeFilename(file?.filename || key),
|
||
alt_text: null,
|
||
caption: null,
|
||
tags: [],
|
||
notes: 'uploaded by playwright mock',
|
||
body: file?.body?.toString('utf8') || `mock media body for ${key}`,
|
||
content_type: String(file?.contentType || '').includes('svg')
|
||
? CONTENT_TYPES.svg
|
||
: String(file?.contentType || '').includes('image/')
|
||
? String(file.contentType)
|
||
: CONTENT_TYPES.text,
|
||
}
|
||
}
|
||
|
||
function upsertPostFromPayload(current, payload) {
|
||
const next = current ? { ...current } : {}
|
||
const title = normalizeText(payload.title) || normalizeText(current?.title) || '未命名文章'
|
||
const slug = normalizeText(payload.slug) || normalizeText(current?.slug) || slugify(title)
|
||
const content = payload.content ?? current?.content ?? buildMarkdown(title, ['待补充内容。'])
|
||
const parsed = parseHeadingAndDescription(content, title)
|
||
|
||
next.title = title || parsed.title
|
||
next.slug = slug
|
||
next.description = normalizeText(payload.description) || parsed.description || current?.description || ''
|
||
next.content = content
|
||
next.category = normalizeText(payload.category) || null
|
||
next.tags = Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : current?.tags || []
|
||
next.post_type = normalizeText(payload.post_type || payload.postType) || current?.post_type || 'article'
|
||
next.image = normalizeText(payload.image) || null
|
||
next.images = Array.isArray(payload.images) ? payload.images.filter(Boolean) : current?.images || []
|
||
next.pinned = Object.hasOwn(payload, 'pinned') ? Boolean(payload.pinned) : Boolean(current?.pinned)
|
||
next.status =
|
||
normalizeText(payload.status) ||
|
||
(Object.hasOwn(payload, 'published') ? (payload.published ? 'published' : 'draft') : '') ||
|
||
current?.status ||
|
||
'draft'
|
||
next.visibility = normalizeText(payload.visibility) || current?.visibility || 'public'
|
||
next.publish_at = normalizeText(payload.publish_at || payload.publishAt) || null
|
||
next.unpublish_at = normalizeText(payload.unpublish_at || payload.unpublishAt) || null
|
||
next.canonical_url = normalizeText(payload.canonical_url || payload.canonicalUrl) || null
|
||
next.noindex = Object.hasOwn(payload, 'noindex') ? Boolean(payload.noindex) : Boolean(current?.noindex)
|
||
next.og_image = normalizeText(payload.og_image || payload.ogImage) || null
|
||
next.redirect_from = Array.isArray(payload.redirect_from || payload.redirectFrom)
|
||
? (payload.redirect_from || payload.redirectFrom).filter(Boolean)
|
||
: current?.redirect_from || []
|
||
next.redirect_to = normalizeText(payload.redirect_to || payload.redirectTo) || null
|
||
next.created_at = current?.created_at || iso(-1)
|
||
next.updated_at = iso(-1)
|
||
|
||
return next
|
||
}
|
||
|
||
function makeTaxonomyRecord(bucket, payload, current = null) {
|
||
return {
|
||
id: current?.id ?? nextId(bucket),
|
||
name: normalizeText(payload.name) || current?.name || '',
|
||
slug: normalizeText(payload.slug) || slugify(payload.name || current?.name || ''),
|
||
count: current?.count ?? 0,
|
||
description: normalizeText(payload.description) || null,
|
||
cover_image: normalizeText(payload.cover_image || payload.coverImage) || null,
|
||
accent_color: normalizeText(payload.accent_color || payload.accentColor) || null,
|
||
seo_title: normalizeText(payload.seo_title || payload.seoTitle) || null,
|
||
seo_description: normalizeText(payload.seo_description || payload.seoDescription) || null,
|
||
created_at: current?.created_at || iso(-1),
|
||
updated_at: iso(-1),
|
||
}
|
||
}
|
||
|
||
function findRevisionById(id) {
|
||
return state.post_revisions.find((item) => item.id === id) || null
|
||
}
|
||
|
||
function resetState() {
|
||
state = createInitialState()
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('seed.bootstrap', 'workspace', 'playwright-smoke', 'seed', {
|
||
posts: state.posts.length,
|
||
reviews: state.reviews.length,
|
||
})
|
||
for (const post of state.posts.slice().reverse()) {
|
||
addPostRevision(post, 'seed', '初始 mock 数据')
|
||
}
|
||
}
|
||
|
||
resetState()
|
||
|
||
function normalizePostResponse(post) {
|
||
return {
|
||
created_at: post.created_at,
|
||
updated_at: post.updated_at,
|
||
id: post.id,
|
||
title: post.title,
|
||
slug: post.slug,
|
||
description: post.description,
|
||
content: post.content,
|
||
category: post.category,
|
||
tags: [...(post.tags || [])],
|
||
post_type: post.post_type,
|
||
image: post.image,
|
||
images: [...(post.images || [])],
|
||
pinned: post.pinned,
|
||
status: post.status,
|
||
visibility: post.visibility,
|
||
publish_at: post.publish_at,
|
||
unpublish_at: post.unpublish_at,
|
||
canonical_url: post.canonical_url,
|
||
noindex: post.noindex,
|
||
og_image: post.og_image,
|
||
redirect_from: [...(post.redirect_from || [])],
|
||
redirect_to: post.redirect_to,
|
||
}
|
||
}
|
||
|
||
function sortItems(items, sortBy = 'created_at', sortOrder = 'desc') {
|
||
const direction = String(sortOrder || 'desc').toLowerCase() === 'asc' ? 1 : -1
|
||
const key = sortBy || 'created_at'
|
||
|
||
return [...items].sort((left, right) => {
|
||
const leftValue = left[key] ?? ''
|
||
const rightValue = right[key] ?? ''
|
||
if (leftValue < rightValue) return -1 * direction
|
||
if (leftValue > rightValue) return 1 * direction
|
||
return (right.id || 0) - (left.id || 0)
|
||
})
|
||
}
|
||
|
||
function filterPostsForQuery(items, searchParams) {
|
||
const slug = normalizeText(searchParams.get('slug'))
|
||
const category = normalizeText(searchParams.get('category'))
|
||
const tag = normalizeText(searchParams.get('tag'))
|
||
const search = normalizeText(searchParams.get('search'))
|
||
const postType = normalizeText(searchParams.get('type'))
|
||
const status = normalizeText(searchParams.get('status'))
|
||
const visibility = normalizeText(searchParams.get('visibility'))
|
||
const pinned = searchParams.get('pinned')
|
||
|
||
return items.filter((post) => {
|
||
if (slug && post.slug !== slug) return false
|
||
if (category && normalizeText(post.category).toLowerCase() !== category.toLowerCase()) return false
|
||
if (tag && !(post.tags || []).some((item) => normalizeText(item).toLowerCase() === tag.toLowerCase())) {
|
||
return false
|
||
}
|
||
if (postType && normalizeText(post.post_type).toLowerCase() !== postType.toLowerCase()) return false
|
||
if (status && normalizeText(post.status).toLowerCase() !== status.toLowerCase()) return false
|
||
if (visibility && normalizeText(post.visibility).toLowerCase() !== visibility.toLowerCase()) return false
|
||
if (pinned !== null && String(post.pinned) !== pinned) return false
|
||
if (!search) return true
|
||
|
||
const haystack = [
|
||
post.title,
|
||
post.slug,
|
||
post.description,
|
||
post.content,
|
||
post.category,
|
||
...(post.tags || []),
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n')
|
||
.toLowerCase()
|
||
|
||
return haystack.includes(search.toLowerCase())
|
||
})
|
||
}
|
||
|
||
function buildPagedResponse(items, searchParams, mapper = (value) => value) {
|
||
const page = Math.max(1, Number.parseInt(searchParams.get('page') || '1', 10) || 1)
|
||
const pageSize = Math.max(1, Number.parseInt(searchParams.get('page_size') || '10', 10) || 10)
|
||
const sortBy = searchParams.get('sort_by') || 'created_at'
|
||
const sortOrder = searchParams.get('sort_order') || 'desc'
|
||
const sorted = sortItems(items, sortBy, sortOrder)
|
||
const total = sorted.length
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||
const safePage = Math.min(page, totalPages)
|
||
const start = (safePage - 1) * pageSize
|
||
|
||
return {
|
||
items: sorted.slice(start, start + pageSize).map(mapper),
|
||
page: safePage,
|
||
page_size: pageSize,
|
||
total,
|
||
total_pages: totalPages,
|
||
sort_by: sortBy,
|
||
sort_order: sortOrder,
|
||
}
|
||
}
|
||
|
||
function makeSearchResult(post, rank) {
|
||
return {
|
||
id: post.id,
|
||
title: post.title,
|
||
slug: post.slug,
|
||
description: post.description,
|
||
content: post.content,
|
||
category: post.category,
|
||
tags: [...(post.tags || [])],
|
||
post_type: post.post_type,
|
||
image: post.image,
|
||
pinned: post.pinned,
|
||
created_at: post.created_at,
|
||
updated_at: post.updated_at,
|
||
rank,
|
||
}
|
||
}
|
||
|
||
function searchPosts(query, filters = {}) {
|
||
const normalizedQuery = normalizeText(query).toLowerCase()
|
||
let items = state.posts.filter((post) => isPostVisible(post))
|
||
if (filters.category) {
|
||
items = items.filter((post) => normalizeText(post.category).toLowerCase() === normalizeText(filters.category).toLowerCase())
|
||
}
|
||
if (filters.tag) {
|
||
items = items.filter((post) =>
|
||
(post.tags || []).some((item) => normalizeText(item).toLowerCase() === normalizeText(filters.tag).toLowerCase()),
|
||
)
|
||
}
|
||
if (filters.type) {
|
||
items = items.filter((post) => normalizeText(post.post_type).toLowerCase() === normalizeText(filters.type).toLowerCase())
|
||
}
|
||
|
||
return items
|
||
.map((post) => {
|
||
const haystack = [post.title, post.description, post.content, post.category, ...(post.tags || [])]
|
||
.filter(Boolean)
|
||
.join('\n')
|
||
.toLowerCase()
|
||
let score = 0
|
||
if (normalizedQuery && haystack.includes(normalizedQuery)) score += 20
|
||
if (post.title.toLowerCase().includes(normalizedQuery)) score += 30
|
||
if ((post.tags || []).some((tag) => tag.toLowerCase().includes(normalizedQuery))) score += 10
|
||
if (post.pinned) score += 5
|
||
return { post, score }
|
||
})
|
||
.filter((item) => item.score > 0)
|
||
.sort((left, right) => right.score - left.score || right.post.id - left.post.id)
|
||
.map((item) => makeSearchResult(item.post, item.score))
|
||
}
|
||
|
||
function issueCaptcha() {
|
||
const token = randomUUID()
|
||
state.captcha_tokens.set(token, { answer: '7', question: '3 + 4 = ?' })
|
||
return { token, question: '3 + 4 = ?', expires_in_seconds: 300 }
|
||
}
|
||
|
||
function verifyCaptcha(token, answer) {
|
||
const challenge = state.captcha_tokens.get(String(token || ''))
|
||
return Boolean(challenge) && normalizeText(answer) === String(challenge.answer)
|
||
}
|
||
|
||
function getCommentMatcherValue(comment, matcherType) {
|
||
if (matcherType === 'email') return normalizeText(comment.email).toLowerCase()
|
||
if (matcherType === 'user_agent') return normalizeText(comment.user_agent).toLowerCase()
|
||
return normalizeText(comment.ip_address).toLowerCase()
|
||
}
|
||
|
||
function isBlacklistedComment(comment) {
|
||
return state.comment_blacklist.some((item) => {
|
||
if (!item.active) return false
|
||
return getCommentMatcherValue(comment, item.matcher_type) === normalizeText(item.matcher_value).toLowerCase()
|
||
})
|
||
}
|
||
|
||
function createMockImageSvg(label, accent = '#14b8a6') {
|
||
const safeLabel = String(label || 'mock asset')
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><defs><linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0%" stop-color="#0f172a"/><stop offset="100%" stop-color="${accent}"/></linearGradient></defs><rect width="1200" height="675" fill="url(#bg)"/><text x="80" y="180" fill="#e2e8f0" font-family="monospace" font-size="44">${safeLabel}</text><text x="80" y="585" fill="#cbd5e1" font-family="monospace" font-size="24">playwright mock asset</text></svg>`
|
||
}
|
||
|
||
function sendSvg(res, label, accent) {
|
||
text(res, 200, createMockImageSvg(label, accent), {
|
||
'content-type': CONTENT_TYPES.svg,
|
||
'cache-control': 'public, max-age=60',
|
||
})
|
||
}
|
||
|
||
function writeEmpty(res, status = 204, headers = {}) {
|
||
res.writeHead(status, headers)
|
||
res.end()
|
||
}
|
||
|
||
function notFound(res, message = 'not found') {
|
||
json(res, 404, { error: 'not_found', description: message })
|
||
}
|
||
|
||
function badRequest(res, message) {
|
||
json(res, 400, { error: 'bad_request', description: message })
|
||
}
|
||
|
||
function latestDebugState() {
|
||
return {
|
||
site_settings: clone(state.site_settings),
|
||
categories: state.categories.map((item) => clone(item)),
|
||
tags: state.tags.map((item) => clone(item)),
|
||
posts: state.posts.map((item) => ({
|
||
id: item.id,
|
||
slug: item.slug,
|
||
title: item.title,
|
||
category: item.category,
|
||
tags: [...(item.tags || [])],
|
||
status: item.status,
|
||
visibility: item.visibility,
|
||
content: item.content,
|
||
updated_at: item.updated_at,
|
||
})),
|
||
reviews: state.reviews.map((item) => clone(item)),
|
||
subscriptions: state.subscriptions.map((item) => ({
|
||
id: item.id,
|
||
target: item.target,
|
||
display_name: item.display_name,
|
||
channel_type: item.channel_type,
|
||
confirm_token: item.confirm_token,
|
||
manage_token: item.manage_token,
|
||
status: item.status,
|
||
filters: clone(item.filters),
|
||
})),
|
||
deliveries: state.deliveries.map((item) => clone(item)),
|
||
media: state.media.map((item) => ({
|
||
key: item.key,
|
||
title: item.title,
|
||
alt_text: item.alt_text,
|
||
tags: [...item.tags],
|
||
url: item.url,
|
||
size_bytes: item.size_bytes,
|
||
})),
|
||
friend_links: state.friend_links.map((item) => ({
|
||
id: item.id,
|
||
site_name: item.site_name,
|
||
status: item.status,
|
||
})),
|
||
comments: state.comments.map((item) => ({
|
||
id: item.id,
|
||
post_slug: item.post_slug,
|
||
scope: item.scope,
|
||
approved: item.approved,
|
||
author: item.author,
|
||
})),
|
||
comment_blacklist: state.comment_blacklist.map((item) => clone(item)),
|
||
post_revisions: state.post_revisions.map((item) => ({
|
||
id: item.id,
|
||
post_slug: item.post_slug,
|
||
operation: item.operation,
|
||
revision_reason: item.revision_reason,
|
||
created_at: item.created_at,
|
||
})),
|
||
audit_logs: state.audit_logs.map((item) => clone(item)),
|
||
}
|
||
}
|
||
|
||
function getHomePayload() {
|
||
const visiblePosts = state.posts.filter((post) => isPostVisible(post))
|
||
const content_ranges = [
|
||
{
|
||
key: '24h',
|
||
label: '24h',
|
||
days: 1,
|
||
overview: { page_views: 18, read_completes: 4, avg_read_progress: 61, avg_read_duration_ms: 42000 },
|
||
popular_posts: [
|
||
{
|
||
slug: 'astro-terminal-blog',
|
||
title: 'Astro 终端博客信息架构实战',
|
||
page_views: 12,
|
||
read_completes: 3,
|
||
avg_progress_percent: 76,
|
||
avg_duration_ms: 51000,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
key: '7d',
|
||
label: '7d',
|
||
days: 7,
|
||
overview: { page_views: 142, read_completes: 57, avg_read_progress: 74, avg_read_duration_ms: 58400 },
|
||
popular_posts: [
|
||
{
|
||
slug: 'playwright-regression-workflow',
|
||
title: 'Playwright 回归工作流设计',
|
||
page_views: 48,
|
||
read_completes: 25,
|
||
avg_progress_percent: 83,
|
||
avg_duration_ms: 61000,
|
||
},
|
||
{
|
||
slug: 'astro-terminal-blog',
|
||
title: 'Astro 终端博客信息架构实战',
|
||
page_views: 38,
|
||
read_completes: 18,
|
||
avg_progress_percent: 71,
|
||
avg_duration_ms: 54000,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
key: '30d',
|
||
label: '30d',
|
||
days: 30,
|
||
overview: { page_views: 580, read_completes: 223, avg_read_progress: 69, avg_read_duration_ms: 50000 },
|
||
popular_posts: [
|
||
{
|
||
slug: 'playwright-regression-workflow',
|
||
title: 'Playwright 回归工作流设计',
|
||
page_views: 152,
|
||
read_completes: 79,
|
||
avg_progress_percent: 81,
|
||
avg_duration_ms: 63000,
|
||
},
|
||
{
|
||
slug: 'docker-rollout-checklist',
|
||
title: 'Docker 发布值班清单',
|
||
page_views: 118,
|
||
read_completes: 42,
|
||
avg_progress_percent: 64,
|
||
avg_duration_ms: 47000,
|
||
},
|
||
],
|
||
},
|
||
]
|
||
|
||
return {
|
||
site_settings: clone(state.site_settings),
|
||
posts: visiblePosts.map(normalizePostResponse),
|
||
tags: state.tags.map((item) => clone(item)),
|
||
friend_links: state.friend_links.map((item) => clone(item)),
|
||
categories: state.categories.map((item) => clone(item)),
|
||
content_overview: {
|
||
total_page_views: 1642,
|
||
page_views_last_24h: 18,
|
||
page_views_last_7d: 142,
|
||
total_read_completes: 628,
|
||
read_completes_last_7d: 57,
|
||
avg_read_progress_last_7d: 74,
|
||
avg_read_duration_ms_last_7d: 58400,
|
||
},
|
||
popular_posts: content_ranges[1].popular_posts,
|
||
content_ranges,
|
||
}
|
||
}
|
||
|
||
const server = createServer(async (req, res) => {
|
||
reflectCors(req, res)
|
||
|
||
if (req.method === 'OPTIONS') {
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
const url = new URL(req.url || '/', MOCK_ORIGIN)
|
||
const { pathname, searchParams } = url
|
||
|
||
if (pathname === '/__playwright/health') {
|
||
json(res, 200, { ok: true })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/__playwright/reset' && req.method === 'POST') {
|
||
resetState()
|
||
json(res, 200, { ok: true })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/__playwright/state') {
|
||
json(res, 200, latestDebugState())
|
||
return
|
||
}
|
||
|
||
if (pathname.startsWith('/media-files/') || pathname.startsWith('/review-covers/') || pathname.startsWith('/generated/')) {
|
||
sendSvg(res, pathname.split('/').pop()?.replace(/\.(svg|png|jpg|jpeg)$/i, '') || 'asset')
|
||
return
|
||
}
|
||
|
||
if (pathname.startsWith('/media/')) {
|
||
const key = decodeURIComponent(pathname.replace('/media/', ''))
|
||
const media = state.media.find((item) => item.key === key)
|
||
if (!media) {
|
||
notFound(res, '媒体不存在。')
|
||
return
|
||
}
|
||
if (media.content_type === CONTENT_TYPES.svg) {
|
||
sendSvg(res, media.title || media.key, '#8b5cf6')
|
||
return
|
||
}
|
||
text(res, 200, media.body, { 'content-type': media.content_type })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/session' && req.method === 'GET') {
|
||
json(res, 200, createSessionResponse(isAuthenticated(req)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/session/login' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
if (
|
||
normalizeText(payload.username) !== VALID_LOGIN.username ||
|
||
normalizeText(payload.password) !== VALID_LOGIN.password
|
||
) {
|
||
badRequest(res, '用户名或密码错误。')
|
||
return
|
||
}
|
||
json(res, 200, createSessionResponse(true), {
|
||
'set-cookie': `${SESSION_COOKIE}=${SESSION_VALUE}; Path=/; HttpOnly; SameSite=Lax`,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/session' && req.method === 'DELETE') {
|
||
json(res, 200, createSessionResponse(false), {
|
||
'set-cookie': `${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/site_settings' && req.method === 'GET') {
|
||
json(res, 200, clone(state.site_settings))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/site_settings/home' && req.method === 'GET') {
|
||
json(res, 200, getHomePayload())
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/categories' && req.method === 'GET') {
|
||
json(res, 200, state.categories.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/tags' && req.method === 'GET') {
|
||
json(res, 200, state.tags.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/reviews' && req.method === 'GET') {
|
||
json(res, 200, state.reviews.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/reviews' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const title = normalizeText(payload.title)
|
||
if (!title) {
|
||
badRequest(res, '评测标题不能为空。')
|
||
return
|
||
}
|
||
|
||
const record = {
|
||
id: nextId('review'),
|
||
title,
|
||
review_type: normalizeText(payload.review_type) || 'book',
|
||
rating: Number(payload.rating) || 4,
|
||
review_date: normalizeText(payload.review_date) || '2026-04-01',
|
||
status: normalizeText(payload.status) || 'draft',
|
||
description: normalizeText(payload.description) || '',
|
||
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : []),
|
||
cover: normalizeText(payload.cover) || '',
|
||
link_url: normalizeText(payload.link_url) || null,
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
}
|
||
|
||
state.reviews.unshift(record)
|
||
addAuditLog('review.create', 'review', record.title, record.id)
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'GET') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const review = state.reviews.find((item) => item.id === id)
|
||
if (!review) {
|
||
notFound(res, '评测不存在。')
|
||
return
|
||
}
|
||
json(res, 200, clone(review))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'PUT') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const record = state.reviews.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '评测不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
record.title = normalizeText(payload.title) || record.title
|
||
record.review_type = normalizeText(payload.review_type) || record.review_type
|
||
record.rating = Number(payload.rating) || record.rating
|
||
record.review_date = normalizeText(payload.review_date) || record.review_date
|
||
record.status = normalizeText(payload.status) || record.status
|
||
record.description = normalizeText(payload.description) || ''
|
||
record.tags = JSON.stringify(Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : [])
|
||
record.cover = normalizeText(payload.cover) || ''
|
||
record.link_url = normalizeText(payload.link_url) || null
|
||
record.updated_at = iso(-1)
|
||
addAuditLog('review.update', 'review', record.title, record.id)
|
||
json(res, 200, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'DELETE') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.reviews.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '评测不存在。')
|
||
return
|
||
}
|
||
const [removed] = state.reviews.splice(index, 1)
|
||
addAuditLog('review.delete', 'review', removed.title, removed.id)
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/friend_links' && req.method === 'GET') {
|
||
let items = [...state.friend_links]
|
||
const status = normalizeText(searchParams.get('status'))
|
||
const category = normalizeText(searchParams.get('category'))
|
||
if (status) items = items.filter((item) => normalizeText(item.status).toLowerCase() === status.toLowerCase())
|
||
if (category) items = items.filter((item) => normalizeText(item.category).toLowerCase() === category.toLowerCase())
|
||
json(res, 200, items.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/friend_links' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = {
|
||
id: nextId('friend_link'),
|
||
site_name: normalizeText(payload.siteName || payload.site_name),
|
||
site_url: normalizeText(payload.siteUrl || payload.site_url),
|
||
avatar_url: null,
|
||
description: normalizeText(payload.description) || null,
|
||
category: normalizeText(payload.category) || 'other',
|
||
status: normalizeText(payload.status) || 'pending',
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
}
|
||
if (!record.site_name || !record.site_url) {
|
||
badRequest(res, '站点名称和 URL 不能为空。')
|
||
return
|
||
}
|
||
state.friend_links.unshift(record)
|
||
addAuditLog('friend_link.create', 'friend_link', record.site_name, record.id, { status: record.status })
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/comments/captcha' && req.method === 'GET') {
|
||
json(res, 200, issueCaptcha())
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/comments/paragraphs/summary' && req.method === 'GET') {
|
||
const postSlug = normalizeText(searchParams.get('post_slug'))
|
||
const counts = new Map()
|
||
for (const comment of state.comments) {
|
||
if (comment.post_slug !== postSlug || comment.scope !== 'paragraph' || !comment.approved) continue
|
||
if (!comment.paragraph_key) continue
|
||
counts.set(comment.paragraph_key, (counts.get(comment.paragraph_key) || 0) + 1)
|
||
}
|
||
json(res, 200, Array.from(counts.entries()).map(([paragraph_key, count]) => ({ paragraph_key, count })))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/comments' && req.method === 'GET') {
|
||
let items = [...state.comments]
|
||
const postSlug = normalizeText(searchParams.get('post_slug'))
|
||
const scope = normalizeText(searchParams.get('scope'))
|
||
const paragraphKey = normalizeText(searchParams.get('paragraph_key'))
|
||
const approved = searchParams.get('approved')
|
||
if (postSlug) items = items.filter((item) => item.post_slug === postSlug)
|
||
if (scope) items = items.filter((item) => item.scope === scope)
|
||
if (paragraphKey) items = items.filter((item) => item.paragraph_key === paragraphKey)
|
||
if (approved !== null) items = items.filter((item) => String(Boolean(item.approved)) === approved)
|
||
json(res, 200, items.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/comments' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const post = state.posts.find((item) => item.slug === normalizeText(payload.postSlug))
|
||
if (!post) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
if (state.site_settings.comment_verification_mode === 'captcha' && !verifyCaptcha(payload.captchaToken, payload.captchaAnswer)) {
|
||
badRequest(res, '验证码错误。')
|
||
return
|
||
}
|
||
const record = {
|
||
id: nextId('comment'),
|
||
post_id: String(post.id),
|
||
post_slug: post.slug,
|
||
author: normalizeText(payload.nickname) || '匿名访客',
|
||
email: normalizeText(payload.email) || null,
|
||
avatar: null,
|
||
ip_address: '10.0.0.88',
|
||
user_agent: String(req.headers['user-agent'] || 'Playwright'),
|
||
referer: String(req.headers.referer || ''),
|
||
content: normalizeText(payload.content),
|
||
reply_to: null,
|
||
reply_to_comment_id: payload.replyToCommentId === null || payload.replyToCommentId === undefined ? null : Number(payload.replyToCommentId),
|
||
scope: normalizeText(payload.scope) || 'article',
|
||
paragraph_key: normalizeText(payload.paragraphKey) || null,
|
||
paragraph_excerpt: normalizeText(payload.paragraphExcerpt) || null,
|
||
approved: false,
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
}
|
||
if (!record.content) {
|
||
badRequest(res, '评论内容不能为空。')
|
||
return
|
||
}
|
||
if (isBlacklistedComment(record)) {
|
||
json(res, 403, { error: 'comment_blocked', description: '该评论命中了黑名单规则,已被拒绝。' })
|
||
return
|
||
}
|
||
state.comments.unshift(record)
|
||
addAuditLog('comment.create', 'comment', record.author, record.id, { scope: record.scope, approved: record.approved })
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/posts' && req.method === 'GET') {
|
||
const candidates = state.posts.filter((post) =>
|
||
isPostVisible(post, {
|
||
include_private: searchParams.get('include_private'),
|
||
preview: searchParams.get('preview'),
|
||
include_redirects: searchParams.get('include_redirects'),
|
||
}),
|
||
)
|
||
const filtered = filterPostsForQuery(candidates, searchParams)
|
||
json(res, 200, filtered.map(normalizePostResponse))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/posts/page' && req.method === 'GET') {
|
||
const candidates = state.posts.filter((post) =>
|
||
isPostVisible(post, {
|
||
include_private: searchParams.get('include_private'),
|
||
preview: searchParams.get('preview'),
|
||
include_redirects: searchParams.get('include_redirects'),
|
||
}),
|
||
)
|
||
const filtered = filterPostsForQuery(candidates, searchParams)
|
||
json(res, 200, buildPagedResponse(filtered, searchParams, normalizePostResponse))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/posts\/\d+$/) && req.method === 'GET') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const post = state.posts.find((item) => item.id === id)
|
||
if (!post || !isPostVisible(post, { preview: true, include_private: true, include_redirects: true })) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
json(res, 200, normalizePostResponse(post))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/posts\/slug\/[^/]+$/) && req.method === 'GET') {
|
||
const slug = decodeURIComponent(pathname.split('/')[4])
|
||
const post = state.posts.find((item) => item.slug === slug)
|
||
if (!post || !isPostVisible(post, {
|
||
include_private: searchParams.get('include_private'),
|
||
preview: searchParams.get('preview'),
|
||
include_redirects: searchParams.get('include_redirects'),
|
||
})) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
json(res, 200, normalizePostResponse(post))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/posts/markdown' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = upsertPostFromPayload(
|
||
{
|
||
id: nextId('post'),
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
},
|
||
payload,
|
||
)
|
||
if (!record.slug || !record.title) {
|
||
badRequest(res, '文章标题不能为空。')
|
||
return
|
||
}
|
||
|
||
state.posts.unshift(record)
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('post.create', 'post', record.title, record.id, { slug: record.slug })
|
||
addPostRevision(record, 'create', '创建草稿')
|
||
json(res, 201, {
|
||
slug: record.slug,
|
||
path: `content/posts/${record.slug}.md`,
|
||
markdown: record.content,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/posts/markdown/import' && req.method === 'POST') {
|
||
const { files } = await parseRequest(req)
|
||
const slugs = []
|
||
|
||
for (const file of files) {
|
||
const source = file.body?.toString('utf8') || '# Imported Post\n\nImported by playwright.'
|
||
const { title, description } = parseHeadingAndDescription(source, sanitizeFilename(file.filename, 'imported'))
|
||
const slug = slugify(title || sanitizeFilename(file.filename, 'imported'))
|
||
const record = {
|
||
id: nextId('post'),
|
||
title,
|
||
slug,
|
||
description,
|
||
content: source,
|
||
category: '测试体系',
|
||
tags: ['Playwright'],
|
||
post_type: 'article',
|
||
image: null,
|
||
images: [],
|
||
pinned: false,
|
||
status: 'draft',
|
||
visibility: 'public',
|
||
publish_at: null,
|
||
unpublish_at: null,
|
||
canonical_url: null,
|
||
noindex: false,
|
||
og_image: null,
|
||
redirect_from: [],
|
||
redirect_to: null,
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
}
|
||
state.posts.unshift(record)
|
||
addPostRevision(record, 'import', `导入 ${file.filename}`)
|
||
slugs.push(slug)
|
||
}
|
||
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('post.import', 'workspace', 'markdown import', String(slugs.length), { slugs })
|
||
json(res, 200, { count: slugs.length, slugs })
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/posts\/\d+$/) && req.method === 'PATCH') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const record = state.posts.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
const updated = upsertPostFromPayload(record, payload)
|
||
Object.assign(record, updated)
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('post.update', 'post', record.title, record.id, { slug: record.slug })
|
||
addPostRevision(record, 'update', '保存文章属性')
|
||
json(res, 200, normalizePostResponse(record))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/search' && req.method === 'GET') {
|
||
const q = searchParams.get('q') || ''
|
||
const limit = Math.max(1, Number.parseInt(searchParams.get('limit') || '20', 10) || 20)
|
||
json(res, 200, searchPosts(q).slice(0, limit))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/search/page' && req.method === 'GET') {
|
||
const q = searchParams.get('q') || ''
|
||
const results = searchPosts(q, {
|
||
category: searchParams.get('category'),
|
||
tag: searchParams.get('tag'),
|
||
type: searchParams.get('type'),
|
||
})
|
||
json(res, 200, { query: q, ...buildPagedResponse(results, searchParams) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/subscriptions' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const id = nextId('subscription')
|
||
const record = createSubscriptionRecord(id, {
|
||
target: normalizeText(payload.email) || `subscriber-${id}@example.com`,
|
||
display_name: normalizeText(payload.displayName) || null,
|
||
status: 'pending_confirmation',
|
||
filters: { event_types: ['post.published', 'digest.weekly'], categories: [], tags: [] },
|
||
metadata: { source: normalizeText(payload.source) || 'popup' },
|
||
verified_at: null,
|
||
last_notified_at: null,
|
||
last_delivery_status: null,
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
})
|
||
state.subscriptions.unshift(record)
|
||
addAuditLog('subscription.create', 'subscription', record.target, record.id, { status: record.status })
|
||
json(res, 200, {
|
||
ok: true,
|
||
subscription_id: record.id,
|
||
status: record.status,
|
||
requires_confirmation: true,
|
||
message: '订阅已登记,请前往确认页完成激活。',
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/subscriptions/confirm' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = state.subscriptions.find((item) => item.confirm_token === normalizeText(payload.token))
|
||
if (!record) {
|
||
badRequest(res, '确认令牌无效。')
|
||
return
|
||
}
|
||
record.status = 'active'
|
||
record.verified_at = iso(-1)
|
||
record.updated_at = iso(-1)
|
||
json(res, 200, { ok: true, subscription: clone(record) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/subscriptions/manage' && req.method === 'GET') {
|
||
const token = normalizeText(searchParams.get('token'))
|
||
const record = state.subscriptions.find((item) => item.manage_token === token)
|
||
if (!record) {
|
||
badRequest(res, '管理令牌无效。')
|
||
return
|
||
}
|
||
json(res, 200, { ok: true, subscription: clone(record) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/subscriptions/manage' && req.method === 'PATCH') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = state.subscriptions.find((item) => item.manage_token === normalizeText(payload.token))
|
||
if (!record) {
|
||
badRequest(res, '管理令牌无效。')
|
||
return
|
||
}
|
||
record.display_name = payload.displayName ?? record.display_name
|
||
record.status = normalizeText(payload.status) || record.status
|
||
record.filters = payload.filters ?? record.filters
|
||
record.updated_at = iso(-1)
|
||
json(res, 200, { ok: true, subscription: clone(record) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/subscriptions/unsubscribe' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = state.subscriptions.find((item) => item.manage_token === normalizeText(payload.token))
|
||
if (!record) {
|
||
badRequest(res, '管理令牌无效。')
|
||
return
|
||
}
|
||
record.status = 'unsubscribed'
|
||
record.updated_at = iso(-1)
|
||
json(res, 200, { ok: true, subscription: clone(record) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/analytics/content' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
state.content_events.unshift({ ...payload, created_at: iso(-1) })
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/ai/ask/stream' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const question = normalizeText(payload.question) || '未提供问题'
|
||
const completePayload = {
|
||
question,
|
||
answer:
|
||
'Termi 的内容主要集中在前端工程、回归测试、值班运维和 AI 工作流。\n\n你可以优先看 Playwright 回归工作流、Astro 终端博客信息架构,以及 Docker 发布值班清单这三篇。',
|
||
sources: state.posts.filter((item) => isPostVisible(item)).slice(0, 3).map((item, index) => ({
|
||
slug: item.slug,
|
||
href: `${FRONTEND_ORIGIN}/articles/${item.slug}`,
|
||
title: item.title,
|
||
excerpt: item.description,
|
||
score: 0.94 - index * 0.07,
|
||
chunk_index: index,
|
||
})),
|
||
indexed_chunks: state.site_settings.ai_chunks_count,
|
||
last_indexed_at: state.site_settings.ai_last_indexed_at,
|
||
}
|
||
res.writeHead(200, {
|
||
'content-type': CONTENT_TYPES.sse,
|
||
'cache-control': 'no-store',
|
||
connection: 'keep-alive',
|
||
})
|
||
res.write(`event: status\ndata: ${JSON.stringify({ stage: 'searching' })}\n\n`)
|
||
for (const chunk of [
|
||
'Termi 的内容主要集中在前端工程、回归测试、值班运维和 AI 工作流。',
|
||
'\n\n你可以优先看 Playwright 回归工作流、Astro 终端博客信息架构,',
|
||
'以及 Docker 发布值班清单这三篇。',
|
||
]) {
|
||
res.write(`event: delta\ndata: ${JSON.stringify({ delta: chunk })}\n\n`)
|
||
}
|
||
res.write(`event: complete\ndata: ${JSON.stringify(completePayload)}\n\n`)
|
||
res.end()
|
||
return
|
||
}
|
||
|
||
if (pathname.startsWith('/api/admin/')) {
|
||
if (!ensureAdmin(req, res)) return
|
||
|
||
if (pathname === '/api/admin/dashboard' && req.method === 'GET') {
|
||
const pendingComments = state.comments.filter((item) => !item.approved)
|
||
const pendingFriendLinks = state.friend_links.filter((item) => item.status === 'pending')
|
||
json(res, 200, {
|
||
stats: {
|
||
total_posts: state.posts.length,
|
||
total_comments: state.comments.length,
|
||
pending_comments: pendingComments.length,
|
||
draft_posts: state.posts.filter((item) => item.status === 'draft').length,
|
||
scheduled_posts: state.posts.filter((item) => item.status === 'scheduled').length,
|
||
offline_posts: 0,
|
||
expired_posts: 0,
|
||
private_posts: state.posts.filter((item) => item.visibility === 'private').length,
|
||
unlisted_posts: state.posts.filter((item) => item.visibility === 'unlisted').length,
|
||
total_categories: state.categories.length,
|
||
total_tags: state.tags.length,
|
||
total_reviews: state.reviews.length,
|
||
total_links: state.friend_links.length,
|
||
pending_links: pendingFriendLinks.length,
|
||
ai_chunks: state.site_settings.ai_chunks_count,
|
||
ai_enabled: state.site_settings.ai_enabled,
|
||
},
|
||
site: {
|
||
site_name: state.site_settings.site_name,
|
||
site_url: state.site_settings.site_url,
|
||
ai_enabled: state.site_settings.ai_enabled,
|
||
ai_chunks: state.site_settings.ai_chunks_count,
|
||
ai_last_indexed_at: state.site_settings.ai_last_indexed_at,
|
||
},
|
||
recent_posts: state.posts.slice(0, 5).map((item) => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
slug: item.slug,
|
||
category: item.category,
|
||
post_type: item.post_type,
|
||
pinned: item.pinned,
|
||
status: item.status,
|
||
visibility: item.visibility,
|
||
created_at: item.created_at,
|
||
})),
|
||
pending_comments: pendingComments.slice(0, 5).map((item) => ({
|
||
id: item.id,
|
||
author: item.author,
|
||
post_slug: item.post_slug,
|
||
scope: item.scope,
|
||
excerpt: item.content?.slice(0, 80) || '',
|
||
approved: Boolean(item.approved),
|
||
created_at: item.created_at,
|
||
})),
|
||
pending_friend_links: pendingFriendLinks.slice(0, 5).map((item) => ({
|
||
id: item.id,
|
||
site_name: item.site_name,
|
||
site_url: item.site_url,
|
||
category: item.category,
|
||
status: item.status,
|
||
created_at: item.created_at,
|
||
})),
|
||
recent_reviews: state.reviews.slice(0, 5).map((item) => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
review_type: item.review_type,
|
||
rating: item.rating,
|
||
status: item.status,
|
||
review_date: item.review_date,
|
||
})),
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/analytics' && req.method === 'GET') {
|
||
json(res, 200, {
|
||
overview: {
|
||
total_searches: 218,
|
||
total_ai_questions: 64,
|
||
searches_last_24h: 12,
|
||
ai_questions_last_24h: 5,
|
||
searches_last_7d: 66,
|
||
ai_questions_last_7d: 18,
|
||
unique_search_terms_last_7d: 21,
|
||
unique_ai_questions_last_7d: 9,
|
||
avg_search_results_last_7d: 5.4,
|
||
avg_ai_latency_ms_last_7d: 286,
|
||
},
|
||
content_overview: {
|
||
total_page_views: 1642,
|
||
page_views_last_24h: 18,
|
||
page_views_last_7d: 142,
|
||
total_read_completes: 628,
|
||
read_completes_last_7d: 57,
|
||
avg_read_progress_last_7d: 74,
|
||
avg_read_duration_ms_last_7d: 58400,
|
||
},
|
||
top_search_terms: [{ query: 'playwright', count: 22, last_seen_at: iso(-8) }],
|
||
top_ai_questions: [{ query: '这个博客主要写什么内容?', count: 8, last_seen_at: iso(-6) }],
|
||
recent_events: [],
|
||
providers_last_7d: [{ provider: 'mock-openai', count: 18 }],
|
||
top_referrers: [{ referrer: 'homepage', count: 44 }],
|
||
popular_posts: getHomePayload().popular_posts,
|
||
daily_activity: [{ date: '2026-04-01', searches: 9, ai_questions: 5 }],
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/categories' && req.method === 'GET') {
|
||
json(res, 200, state.categories.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/categories' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = makeTaxonomyRecord('category', payload)
|
||
state.categories.unshift(record)
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('category.create', 'category', record.name, record.id)
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/categories\/\d+$/) && req.method === 'PATCH') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.categories.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '分类不存在。')
|
||
return
|
||
}
|
||
const current = state.categories[index]
|
||
const { json: payload } = await parseRequest(req)
|
||
const previousName = current.name
|
||
const updated = makeTaxonomyRecord('category', payload, current)
|
||
state.categories[index] = updated
|
||
if (previousName !== updated.name) {
|
||
state.posts.forEach((post) => {
|
||
if (post.category === previousName) {
|
||
post.category = updated.name
|
||
}
|
||
})
|
||
}
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('category.update', 'category', updated.name, updated.id)
|
||
json(res, 200, clone(updated))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/categories\/\d+$/) && req.method === 'DELETE') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.categories.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '分类不存在。')
|
||
return
|
||
}
|
||
const [removed] = state.categories.splice(index, 1)
|
||
state.posts.forEach((post) => {
|
||
if (post.category === removed.name) {
|
||
post.category = null
|
||
}
|
||
})
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('category.delete', 'category', removed.name, removed.id)
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/tags' && req.method === 'GET') {
|
||
json(res, 200, state.tags.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/tags' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = makeTaxonomyRecord('tag', payload)
|
||
state.tags.unshift(record)
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('tag.create', 'tag', record.name, record.id)
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/tags\/\d+$/) && req.method === 'PATCH') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.tags.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '标签不存在。')
|
||
return
|
||
}
|
||
const current = state.tags[index]
|
||
const { json: payload } = await parseRequest(req)
|
||
const previousName = current.name
|
||
const updated = makeTaxonomyRecord('tag', payload, current)
|
||
state.tags[index] = updated
|
||
if (previousName !== updated.name) {
|
||
state.posts.forEach((post) => {
|
||
post.tags = (post.tags || []).map((tag) => (tag === previousName ? updated.name : tag))
|
||
})
|
||
}
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('tag.update', 'tag', updated.name, updated.id)
|
||
json(res, 200, clone(updated))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/tags\/\d+$/) && req.method === 'DELETE') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.tags.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '标签不存在。')
|
||
return
|
||
}
|
||
const [removed] = state.tags.splice(index, 1)
|
||
state.posts.forEach((post) => {
|
||
post.tags = (post.tags || []).filter((tag) => tag !== removed.name)
|
||
})
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('tag.delete', 'tag', removed.name, removed.id)
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/site-settings' && req.method === 'GET') {
|
||
json(res, 200, clone(state.site_settings))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/site-settings' && req.method === 'PATCH') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const fieldMap = {
|
||
siteName: 'site_name',
|
||
siteShortName: 'site_short_name',
|
||
siteUrl: 'site_url',
|
||
siteTitle: 'site_title',
|
||
siteDescription: 'site_description',
|
||
heroTitle: 'hero_title',
|
||
heroSubtitle: 'hero_subtitle',
|
||
ownerName: 'owner_name',
|
||
ownerTitle: 'owner_title',
|
||
ownerBio: 'owner_bio',
|
||
ownerAvatarUrl: 'owner_avatar_url',
|
||
socialGithub: 'social_github',
|
||
socialTwitter: 'social_twitter',
|
||
socialEmail: 'social_email',
|
||
location: 'location',
|
||
techStack: 'tech_stack',
|
||
musicPlaylist: 'music_playlist',
|
||
aiEnabled: 'ai_enabled',
|
||
paragraphCommentsEnabled: 'paragraph_comments_enabled',
|
||
commentVerificationMode: 'comment_verification_mode',
|
||
commentTurnstileEnabled: 'comment_turnstile_enabled',
|
||
subscriptionVerificationMode: 'subscription_verification_mode',
|
||
subscriptionTurnstileEnabled: 'subscription_turnstile_enabled',
|
||
webPushEnabled: 'web_push_enabled',
|
||
turnstileSiteKey: 'turnstile_site_key',
|
||
turnstileSecretKey: 'turnstile_secret_key',
|
||
webPushVapidPublicKey: 'web_push_vapid_public_key',
|
||
webPushVapidPrivateKey: 'web_push_vapid_private_key',
|
||
webPushVapidSubject: 'web_push_vapid_subject',
|
||
aiProvider: 'ai_provider',
|
||
aiApiBase: 'ai_api_base',
|
||
aiApiKey: 'ai_api_key',
|
||
aiChatModel: 'ai_chat_model',
|
||
aiImageProvider: 'ai_image_provider',
|
||
aiImageApiBase: 'ai_image_api_base',
|
||
aiImageApiKey: 'ai_image_api_key',
|
||
aiImageModel: 'ai_image_model',
|
||
aiProviders: 'ai_providers',
|
||
aiActiveProviderId: 'ai_active_provider_id',
|
||
aiEmbeddingModel: 'ai_embedding_model',
|
||
aiSystemPrompt: 'ai_system_prompt',
|
||
aiTopK: 'ai_top_k',
|
||
aiChunkSize: 'ai_chunk_size',
|
||
mediaStorageProvider: 'media_storage_provider',
|
||
mediaR2AccountId: 'media_r2_account_id',
|
||
mediaR2Bucket: 'media_r2_bucket',
|
||
mediaR2PublicBaseUrl: 'media_r2_public_base_url',
|
||
mediaR2AccessKeyId: 'media_r2_access_key_id',
|
||
mediaR2SecretAccessKey: 'media_r2_secret_access_key',
|
||
seoDefaultOgImage: 'seo_default_og_image',
|
||
seoDefaultTwitterHandle: 'seo_default_twitter_handle',
|
||
notificationWebhookUrl: 'notification_webhook_url',
|
||
notificationChannelType: 'notification_channel_type',
|
||
notificationCommentEnabled: 'notification_comment_enabled',
|
||
notificationFriendLinkEnabled: 'notification_friend_link_enabled',
|
||
subscriptionPopupEnabled: 'subscription_popup_enabled',
|
||
subscriptionPopupTitle: 'subscription_popup_title',
|
||
subscriptionPopupDescription: 'subscription_popup_description',
|
||
subscriptionPopupDelaySeconds: 'subscription_popup_delay_seconds',
|
||
searchSynonyms: 'search_synonyms',
|
||
}
|
||
|
||
for (const [sourceKey, targetKey] of Object.entries(fieldMap)) {
|
||
if (Object.hasOwn(payload, sourceKey)) {
|
||
state.site_settings[targetKey] = payload[sourceKey]
|
||
}
|
||
}
|
||
addAuditLog('site_settings.update', 'site_settings', state.site_settings.site_name, '1')
|
||
json(res, 200, clone(state.site_settings))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/audit-logs' && req.method === 'GET') {
|
||
json(res, 200, state.audit_logs.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/post-revisions' && req.method === 'GET') {
|
||
let items = [...state.post_revisions]
|
||
const slug = normalizeText(searchParams.get('slug'))
|
||
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
|
||
if (slug) {
|
||
items = items.filter((item) => item.post_slug === slug)
|
||
}
|
||
if (limit > 0) {
|
||
items = items.slice(0, limit)
|
||
}
|
||
json(res, 200, items.map((item) => ({
|
||
id: item.id,
|
||
post_slug: item.post_slug,
|
||
post_title: item.post_title,
|
||
operation: item.operation,
|
||
revision_reason: item.revision_reason,
|
||
actor_username: item.actor_username,
|
||
actor_email: item.actor_email,
|
||
actor_source: item.actor_source,
|
||
created_at: item.created_at,
|
||
has_markdown: item.has_markdown,
|
||
metadata: clone(item.metadata),
|
||
})))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/post-revisions\/\d+$/) && req.method === 'GET') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const revision = findRevisionById(id)
|
||
if (!revision) {
|
||
notFound(res, '版本不存在。')
|
||
return
|
||
}
|
||
json(res, 200, {
|
||
item: {
|
||
id: revision.id,
|
||
post_slug: revision.post_slug,
|
||
post_title: revision.post_title,
|
||
operation: revision.operation,
|
||
revision_reason: revision.revision_reason,
|
||
actor_username: revision.actor_username,
|
||
actor_email: revision.actor_email,
|
||
actor_source: revision.actor_source,
|
||
created_at: revision.created_at,
|
||
has_markdown: revision.has_markdown,
|
||
metadata: clone(revision.metadata),
|
||
},
|
||
markdown: revision.markdown,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/post-revisions\/\d+\/restore$/) && req.method === 'POST') {
|
||
const id = Number(pathname.split('/')[4])
|
||
const revision = findRevisionById(id)
|
||
if (!revision) {
|
||
notFound(res, '版本不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
const mode = normalizeText(payload.mode) || 'full'
|
||
const post = state.posts.find((item) => item.slug === revision.post_slug)
|
||
if (!post) {
|
||
notFound(res, '原文章不存在。')
|
||
return
|
||
}
|
||
if (mode === 'full') {
|
||
Object.assign(post, clone(revision.snapshot))
|
||
}
|
||
if (mode === 'metadata') {
|
||
const snapshot = revision.snapshot || {}
|
||
Object.assign(post, {
|
||
title: snapshot.title,
|
||
description: snapshot.description,
|
||
category: snapshot.category,
|
||
tags: clone(snapshot.tags || []),
|
||
post_type: snapshot.post_type,
|
||
image: snapshot.image,
|
||
images: clone(snapshot.images || []),
|
||
pinned: snapshot.pinned,
|
||
status: snapshot.status,
|
||
visibility: snapshot.visibility,
|
||
publish_at: snapshot.publish_at,
|
||
unpublish_at: snapshot.unpublish_at,
|
||
canonical_url: snapshot.canonical_url,
|
||
noindex: snapshot.noindex,
|
||
og_image: snapshot.og_image,
|
||
redirect_from: clone(snapshot.redirect_from || []),
|
||
redirect_to: snapshot.redirect_to,
|
||
})
|
||
}
|
||
if (mode === 'markdown') {
|
||
post.content = revision.markdown
|
||
}
|
||
post.updated_at = iso(-1)
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('post.restore', 'post', post.title, post.id, { revision_id: id, mode })
|
||
addPostRevision(post, 'restore', `从版本 #${id} 恢复(${mode})`)
|
||
json(res, 200, { restored: true, revision_id: id, post_slug: post.slug, mode })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/comments/blacklist' && req.method === 'GET') {
|
||
json(res, 200, state.comment_blacklist.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/comments/blacklist' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const record = {
|
||
id: nextId('blacklist'),
|
||
matcher_type: normalizeText(payload.matcher_type) || 'ip',
|
||
matcher_value: normalizeText(payload.matcher_value),
|
||
reason: normalizeText(payload.reason) || null,
|
||
active: Object.hasOwn(payload, 'active') ? Boolean(payload.active) : true,
|
||
expires_at: normalizeText(payload.expires_at) || null,
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
effective: Object.hasOwn(payload, 'active') ? Boolean(payload.active) : true,
|
||
}
|
||
state.comment_blacklist.unshift(record)
|
||
addAuditLog('comment_blacklist.create', 'comment_blacklist', record.matcher_value, record.id)
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/comments\/blacklist\/\d+$/) && req.method === 'PATCH') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const record = state.comment_blacklist.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '黑名单规则不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
if (Object.hasOwn(payload, 'reason')) record.reason = normalizeText(payload.reason) || null
|
||
if (Object.hasOwn(payload, 'active')) record.active = Boolean(payload.active)
|
||
if (Object.hasOwn(payload, 'expires_at')) record.expires_at = normalizeText(payload.expires_at) || null
|
||
if (payload.clear_expires_at) record.expires_at = null
|
||
record.updated_at = iso(-1)
|
||
record.effective = Boolean(record.active) && !record.expires_at
|
||
addAuditLog('comment_blacklist.update', 'comment_blacklist', record.matcher_value, record.id)
|
||
json(res, 200, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/comments\/blacklist\/\d+$/) && req.method === 'DELETE') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.comment_blacklist.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '黑名单规则不存在。')
|
||
return
|
||
}
|
||
const [removed] = state.comment_blacklist.splice(index, 1)
|
||
addAuditLog('comment_blacklist.delete', 'comment_blacklist', removed.matcher_value, removed.id)
|
||
json(res, 200, { deleted: true, id })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/comments/analyze' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const matcherType = normalizeText(payload.matcher_type) || 'ip'
|
||
const matcherValue = normalizeText(payload.matcher_value)
|
||
const matches = state.comments.filter((comment) =>
|
||
getCommentMatcherValue(comment, matcherType) === matcherValue.toLowerCase(),
|
||
)
|
||
const analysis = `该来源共出现 ${matches.length} 条评论,其中待审核 ${
|
||
matches.filter((item) => !item.approved).length
|
||
} 条;建议保持观察并视情况加入黑名单。`
|
||
const log = {
|
||
id: nextId('persona_log'),
|
||
matcher_type: matcherType,
|
||
matcher_value: matcherValue,
|
||
from_at: matches.at(-1)?.created_at ?? null,
|
||
to_at: matches[0]?.created_at ?? null,
|
||
total_comments: matches.length,
|
||
pending_comments: matches.filter((item) => !item.approved).length,
|
||
distinct_posts: new Set(matches.map((item) => item.post_slug)).size,
|
||
analysis,
|
||
samples: matches.slice(0, 3).map((item) => ({
|
||
id: item.id,
|
||
created_at: item.created_at,
|
||
post_slug: item.post_slug,
|
||
author: item.author,
|
||
email: item.email,
|
||
approved: Boolean(item.approved),
|
||
content_preview: String(item.content || '').slice(0, 80),
|
||
})),
|
||
created_at: iso(-1),
|
||
}
|
||
state.comment_persona_logs.unshift(log)
|
||
addAuditLog('comment_persona.analyze', 'comment_persona', matcherValue, log.id)
|
||
json(res, 200, {
|
||
matcher_type: log.matcher_type,
|
||
matcher_value: log.matcher_value,
|
||
total_comments: log.total_comments,
|
||
pending_comments: log.pending_comments,
|
||
first_seen_at: log.from_at,
|
||
latest_seen_at: log.to_at,
|
||
distinct_posts: log.distinct_posts,
|
||
analysis: log.analysis,
|
||
samples: clone(log.samples),
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/comments/analyze/logs' && req.method === 'GET') {
|
||
const matcherType = normalizeText(searchParams.get('matcher_type'))
|
||
const matcherValue = normalizeText(searchParams.get('matcher_value'))
|
||
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
|
||
let items = [...state.comment_persona_logs]
|
||
if (matcherType) {
|
||
items = items.filter((item) => item.matcher_type === matcherType)
|
||
}
|
||
if (matcherValue) {
|
||
items = items.filter((item) => item.matcher_value === matcherValue)
|
||
}
|
||
if (limit > 0) {
|
||
items = items.slice(0, limit)
|
||
}
|
||
json(res, 200, items.map((item) => clone(item)))
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/subscriptions' && req.method === 'GET') {
|
||
json(res, 200, { subscriptions: state.subscriptions.map((item) => clone(item)) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/subscriptions' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const id = nextId('subscription')
|
||
const record = createSubscriptionRecord(id, {
|
||
channel_type: normalizeText(payload.channelType) || 'email',
|
||
target: normalizeText(payload.target) || `user${id}@example.com`,
|
||
display_name: normalizeText(payload.displayName) || null,
|
||
status: normalizeText(payload.status) || 'active',
|
||
filters: payload.filters ?? null,
|
||
metadata: payload.metadata ?? null,
|
||
secret: normalizeText(payload.secret) || null,
|
||
notes: normalizeText(payload.notes) || null,
|
||
created_at: iso(-1),
|
||
updated_at: iso(-1),
|
||
})
|
||
state.subscriptions.unshift(record)
|
||
addAuditLog('subscription.admin_create', 'subscription', record.target, record.id)
|
||
json(res, 201, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/subscriptions\/\d+$/) && req.method === 'PATCH') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const record = state.subscriptions.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '订阅目标不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
if (Object.hasOwn(payload, 'channelType')) record.channel_type = normalizeText(payload.channelType) || record.channel_type
|
||
if (Object.hasOwn(payload, 'target')) record.target = normalizeText(payload.target) || record.target
|
||
if (Object.hasOwn(payload, 'displayName')) record.display_name = normalizeText(payload.displayName) || null
|
||
if (Object.hasOwn(payload, 'status')) record.status = normalizeText(payload.status) || record.status
|
||
if (Object.hasOwn(payload, 'filters')) record.filters = payload.filters ?? null
|
||
if (Object.hasOwn(payload, 'metadata')) record.metadata = payload.metadata ?? null
|
||
if (Object.hasOwn(payload, 'secret')) record.secret = normalizeText(payload.secret) || null
|
||
if (Object.hasOwn(payload, 'notes')) record.notes = normalizeText(payload.notes) || null
|
||
record.updated_at = iso(-1)
|
||
addAuditLog('subscription.update', 'subscription', record.target, record.id)
|
||
json(res, 200, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/subscriptions\/\d+$/) && req.method === 'DELETE') {
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.subscriptions.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '订阅目标不存在。')
|
||
return
|
||
}
|
||
const [removed] = state.subscriptions.splice(index, 1)
|
||
addAuditLog('subscription.delete', 'subscription', removed.target, removed.id)
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/admin\/subscriptions\/\d+\/test$/) && req.method === 'POST') {
|
||
const id = Number(pathname.split('/')[4])
|
||
const record = state.subscriptions.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '订阅目标不存在。')
|
||
return
|
||
}
|
||
const delivery = createNotificationDelivery({
|
||
subscription: record,
|
||
eventType: 'subscription.test',
|
||
status: 'queued',
|
||
})
|
||
state.deliveries.unshift(delivery)
|
||
record.last_delivery_status = delivery.status
|
||
record.last_notified_at = iso(-1)
|
||
addAuditLog('subscription.test', 'subscription', record.target, record.id)
|
||
json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/subscriptions/deliveries' && req.method === 'GET') {
|
||
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
|
||
const items = limit > 0 ? state.deliveries.slice(0, limit) : state.deliveries
|
||
json(res, 200, { deliveries: items.map((item) => clone(item)) })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/subscriptions/digest' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const period = normalizeText(payload.period) || 'weekly'
|
||
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
|
||
activeSubscriptions.forEach((subscription) => {
|
||
state.deliveries.unshift(
|
||
createNotificationDelivery({
|
||
subscription,
|
||
eventType: `digest.${period}`,
|
||
status: 'queued',
|
||
payload: { period },
|
||
}),
|
||
)
|
||
subscription.last_delivery_status = 'queued'
|
||
subscription.last_notified_at = iso(-1)
|
||
})
|
||
addAuditLog('subscription.digest', 'subscription', period, String(activeSubscriptions.length))
|
||
json(res, 200, {
|
||
period,
|
||
post_count: state.posts.filter((item) => item.status === 'published').length,
|
||
queued: activeSubscriptions.length,
|
||
skipped: state.subscriptions.length - activeSubscriptions.length,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/media' && req.method === 'GET') {
|
||
const prefix = normalizeText(searchParams.get('prefix'))
|
||
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
|
||
const items = state.media
|
||
.filter((item) => !prefix || item.key.startsWith(prefix))
|
||
.slice(0, limit > 0 ? limit : undefined)
|
||
json(res, 200, {
|
||
provider: state.site_settings.media_storage_provider,
|
||
bucket: state.site_settings.media_r2_bucket,
|
||
public_base_url: state.site_settings.media_r2_public_base_url,
|
||
items: items.map((item) => ({
|
||
key: item.key,
|
||
url: item.url,
|
||
size_bytes: item.size_bytes,
|
||
last_modified: item.last_modified,
|
||
title: item.title,
|
||
alt_text: item.alt_text,
|
||
caption: item.caption,
|
||
tags: [...item.tags],
|
||
notes: item.notes,
|
||
})),
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/media' && req.method === 'POST') {
|
||
const { fields, files } = await parseRequest(req)
|
||
const prefix = normalizeText(fields.prefix) || 'uploads/'
|
||
const uploaded = files.map((file, index) => {
|
||
const key = `${prefix}${Date.now()}-${index}-${sanitizeFilename(file.filename)}`
|
||
const record = makeMediaRecordFromUpload(key, file)
|
||
state.media.unshift(record)
|
||
return {
|
||
key: record.key,
|
||
url: record.url,
|
||
size_bytes: record.size_bytes,
|
||
}
|
||
})
|
||
addAuditLog('media.upload', 'media', prefix, String(uploaded.length))
|
||
json(res, 200, { uploaded })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/media' && req.method === 'DELETE') {
|
||
const key = decodeURIComponent(searchParams.get('key') || '')
|
||
const index = state.media.findIndex((item) => item.key === key)
|
||
if (index === -1) {
|
||
notFound(res, '媒体对象不存在。')
|
||
return
|
||
}
|
||
state.media.splice(index, 1)
|
||
addAuditLog('media.delete', 'media', key, key)
|
||
json(res, 200, { deleted: true, key })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/media/batch-delete' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const deleted = []
|
||
const failed = []
|
||
for (const key of Array.isArray(payload.keys) ? payload.keys : []) {
|
||
const index = state.media.findIndex((item) => item.key === key)
|
||
if (index === -1) {
|
||
failed.push(key)
|
||
continue
|
||
}
|
||
state.media.splice(index, 1)
|
||
deleted.push(key)
|
||
}
|
||
addAuditLog('media.batch_delete', 'media', `${deleted.length} deleted`, String(deleted.length), { deleted, failed })
|
||
json(res, 200, { deleted, failed })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/media/replace' && req.method === 'POST') {
|
||
const { fields, files } = await parseRequest(req)
|
||
const key = normalizeText(fields.key)
|
||
const record = state.media.find((item) => item.key === key)
|
||
if (!record) {
|
||
notFound(res, '媒体对象不存在。')
|
||
return
|
||
}
|
||
const file = files[0]
|
||
if (file) {
|
||
record.body = file.body?.toString('utf8') || record.body
|
||
record.size_bytes = file.size ?? record.size_bytes
|
||
record.content_type = String(file.contentType || record.content_type)
|
||
}
|
||
record.last_modified = iso(-1)
|
||
addAuditLog('media.replace', 'media', key, key)
|
||
json(res, 200, { key: record.key, url: `${record.url}?v=${Date.now()}` })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/media/metadata' && req.method === 'PATCH') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const key = normalizeText(payload.key)
|
||
const record = state.media.find((item) => item.key === key)
|
||
if (!record) {
|
||
notFound(res, '媒体对象不存在。')
|
||
return
|
||
}
|
||
record.title = normalizeText(payload.title) || null
|
||
record.alt_text = normalizeText(payload.alt_text) || null
|
||
record.caption = normalizeText(payload.caption) || null
|
||
record.tags = Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : []
|
||
record.notes = normalizeText(payload.notes) || null
|
||
record.last_modified = iso(-1)
|
||
addAuditLog('media.metadata.update', 'media', key, key)
|
||
json(res, 200, {
|
||
saved: true,
|
||
key: record.key,
|
||
title: record.title,
|
||
alt_text: record.alt_text,
|
||
caption: record.caption,
|
||
tags: [...record.tags],
|
||
notes: record.notes,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/review-cover' && req.method === 'POST') {
|
||
const { files } = await parseRequest(req)
|
||
const file = files[0]
|
||
if (!file) {
|
||
badRequest(res, '缺少封面文件。')
|
||
return
|
||
}
|
||
const key = `review-covers/${Date.now()}-${sanitizeFilename(file.filename, 'review-cover.svg')}`
|
||
const record = makeMediaRecordFromUpload(key, file)
|
||
state.media.unshift(record)
|
||
addAuditLog('review.cover.upload', 'media', key, key)
|
||
json(res, 200, { key: record.key, url: record.url })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/reindex' && req.method === 'POST') {
|
||
state.site_settings.ai_last_indexed_at = iso(-1)
|
||
state.site_settings.ai_chunks_count += 3
|
||
json(res, 200, {
|
||
indexed_chunks: state.site_settings.ai_chunks_count,
|
||
last_indexed_at: state.site_settings.ai_last_indexed_at,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/post-metadata' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const markdown = String(payload.markdown || '')
|
||
const { title, description } = parseHeadingAndDescription(markdown, 'Playwright Mock Draft')
|
||
json(res, 200, {
|
||
title,
|
||
description,
|
||
category: '测试体系',
|
||
tags: ['Playwright', 'CI'],
|
||
slug: slugify(title),
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/polish-post' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const markdown = String(payload.markdown || '').trim()
|
||
const polishedMarkdown = markdown.includes('【AI 润色】')
|
||
? markdown
|
||
: `${markdown}\n\n【AI 润色】这是一段由 mock server 追加的润色说明。`
|
||
json(res, 200, { polished_markdown: polishedMarkdown })
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/polish-review' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const description = normalizeText(payload.description) || '暂无点评。'
|
||
json(res, 200, {
|
||
polished_description: `${description}\n\n(AI 润色)整体表达更凝练,也更适合作为首页卡片摘要。`,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/post-cover' && req.method === 'POST') {
|
||
const { json: payload } = await parseRequest(req)
|
||
const slug = normalizeText(payload.slug) || slugify(payload.title || 'mock-cover')
|
||
json(res, 200, {
|
||
image_url: `${MOCK_ORIGIN}/generated/${slug}-cover.svg`,
|
||
prompt: `Generate a mock cover for ${normalizeText(payload.title) || slug}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/test-provider' && req.method === 'POST') {
|
||
json(res, 200, {
|
||
provider: 'openai',
|
||
endpoint: 'https://api.mock.invalid/v1',
|
||
chat_model: 'gpt-mock-4.1',
|
||
reply_preview: 'Mock provider connection looks good.',
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/ai/test-image-provider' && req.method === 'POST') {
|
||
json(res, 200, {
|
||
provider: 'mock-images',
|
||
endpoint: 'https://images.mock.invalid/v1',
|
||
image_model: 'mock-image-1',
|
||
result_preview: `${MOCK_ORIGIN}/generated/image-provider-preview.svg`,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (pathname === '/api/admin/storage/r2/test' && req.method === 'POST') {
|
||
json(res, 200, {
|
||
bucket: state.site_settings.media_r2_bucket,
|
||
public_base_url: state.site_settings.media_r2_public_base_url,
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/posts\/slug\/[^/]+\/markdown$/) && req.method === 'GET') {
|
||
const slug = decodeURIComponent(pathname.split('/')[4])
|
||
const post = state.posts.find((item) => item.slug === slug)
|
||
if (!post) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
json(res, 200, { slug: post.slug, path: `content/posts/${post.slug}.md`, markdown: post.content })
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/posts\/slug\/[^/]+\/markdown$/) && req.method === 'PATCH') {
|
||
const slug = decodeURIComponent(pathname.split('/')[4])
|
||
const post = state.posts.find((item) => item.slug === slug)
|
||
if (!post) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
post.content = String(payload.markdown || post.content || '')
|
||
const parsed = parseHeadingAndDescription(post.content, post.title || post.slug)
|
||
post.title = post.title || parsed.title
|
||
if (!normalizeText(post.description)) {
|
||
post.description = parsed.description
|
||
}
|
||
post.updated_at = iso(-1)
|
||
addAuditLog('post.markdown.update', 'post', post.title, post.id, { slug: post.slug })
|
||
addPostRevision(post, 'markdown', '保存 Markdown')
|
||
json(res, 200, { slug: post.slug, path: `content/posts/${post.slug}.md`, markdown: post.content })
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/posts\/slug\/[^/]+\/markdown$/) && req.method === 'DELETE') {
|
||
const slug = decodeURIComponent(pathname.split('/')[4])
|
||
const index = state.posts.findIndex((item) => item.slug === slug)
|
||
if (index === -1) {
|
||
notFound(res, '文章不存在。')
|
||
return
|
||
}
|
||
const [removed] = state.posts.splice(index, 1)
|
||
recalculateTaxonomyCounts()
|
||
addAuditLog('post.delete', 'post', removed.title, removed.id, { slug: removed.slug })
|
||
addPostRevision(removed, 'delete', '删除文章')
|
||
json(res, 200, { slug: removed.slug, deleted: true })
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/comments\/\d+$/) && req.method === 'PATCH') {
|
||
if (!ensureAdmin(req, res)) return
|
||
const id = Number(pathname.split('/').pop())
|
||
const record = state.comments.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '评论不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
if (Object.hasOwn(payload, 'approved')) record.approved = Boolean(payload.approved)
|
||
record.updated_at = iso(-1)
|
||
addAuditLog('comment.update', 'comment', record.author, record.id, { approved: record.approved })
|
||
json(res, 200, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/comments\/\d+$/) && req.method === 'DELETE') {
|
||
if (!ensureAdmin(req, res)) return
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.comments.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '评论不存在。')
|
||
return
|
||
}
|
||
const [record] = state.comments.splice(index, 1)
|
||
addAuditLog('comment.delete', 'comment', record.author, record.id)
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/friend_links\/\d+$/) && req.method === 'PATCH') {
|
||
if (!ensureAdmin(req, res)) return
|
||
const id = Number(pathname.split('/').pop())
|
||
const record = state.friend_links.find((item) => item.id === id)
|
||
if (!record) {
|
||
notFound(res, '友链不存在。')
|
||
return
|
||
}
|
||
const { json: payload } = await parseRequest(req)
|
||
record.site_name = normalizeText(payload.site_name || payload.siteName) || record.site_name
|
||
record.site_url = normalizeText(payload.site_url || payload.siteUrl) || record.site_url
|
||
record.description = normalizeText(payload.description) || record.description
|
||
record.category = normalizeText(payload.category) || record.category
|
||
record.status = normalizeText(payload.status) || record.status
|
||
record.updated_at = iso(-1)
|
||
addAuditLog('friend_link.update', 'friend_link', record.site_name, record.id, { status: record.status })
|
||
json(res, 200, clone(record))
|
||
return
|
||
}
|
||
|
||
if (pathname.match(/^\/api\/friend_links\/\d+$/) && req.method === 'DELETE') {
|
||
if (!ensureAdmin(req, res)) return
|
||
const id = Number(pathname.split('/').pop())
|
||
const index = state.friend_links.findIndex((item) => item.id === id)
|
||
if (index === -1) {
|
||
notFound(res, '友链不存在。')
|
||
return
|
||
}
|
||
const [record] = state.friend_links.splice(index, 1)
|
||
addAuditLog('friend_link.delete', 'friend_link', record.site_name, record.id)
|
||
writeEmpty(res, 204)
|
||
return
|
||
}
|
||
|
||
notFound(res, `未匹配到 mock 接口:${req.method} ${pathname}`)
|
||
})
|
||
|
||
server.listen(PORT, '127.0.0.1')
|