- M1-M2: 数据层与核心API(DuckDB, entries/tags/comments/search服务) - M3: SSR页面(EJS模板,接真实数据) - M4: 管理后台(登录/用户/内容源/AI ingest) - M5: 采集层(scheduler+rss/html/eryajf适配器) - M6: MCP只读接口(Streamable HTTP, 4工具) - M7: Docker部署(Dockerfile+docker-compose) - 测试: 10个用例全部通过 - 修复: DuckDB连接参数绑定、状态机迁移、标签树操作
17 KiB
17 KiB
Awesome Index · 框架设计
目标:把
design/prototype/的静态原型落地为一个由 DuckDB 驱动、全 JavaScript 编写、 对人与对 AI(MCP)同时提供服务、可 Docker 部署的小型数据库网站。 本文档回答三个问题:模块怎么分、每个模块负责什么、模块对应哪个目录。
1. 总体架构
经典三横层,外加一个并列的机器读者入口:
┌──────────────────────────────────────────┐
人类读者 ────▶ │ 视图层 views/ (EJS SSR) + public/ 静态 │
│ 页面内 <script> 直接 fetch('/api/…') 混入 │
└───────────────┬──────────────────────────┘
│
AI 读者 ─────▶ ┌──────────────┴──────────────────────────┐
(MCP) │ 接口层 routes/(HTTP + MCP) │
│ 参数校验(zod) → 调用 service → 统一响应 │
└───────────────┬──────────────────────────┘
│
外部站点 ────▶ ┌──────────────┴──────────────────────────┐
周刊/RSS │ 采集层 ingest/(定时拉取与整理) │
│ scheduler → adapter 解析 → 去重 → 入库 │
└───────────────┬──────────────────────────┘
│
┌───────────────┴──────────────────────────┐
│ 业务服务层 services/(唯一持有 SQL 处) │
│ 条目 / 标签树 / 评论 / 账号 / 内容源 / 搜索 │
└───────────────┬──────────────────────────┘
│
┌───────────────┴──────────────────────────┐
│ 数据层 db/(DuckDB 单文件库) │
│ data/awesome.duckdb │
└──────────────────────────────────────────┘
关键技术决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| 运行时 | Node.js 22 LTS + ESM | 全站 JavaScript 要求;22 为当前 LTS |
| Web 框架 | Express 5 | 成熟、薄、够用;不引入大而全框架 |
| 数据库 | DuckDB(@duckdb/node-api 官方驱动) |
选型既定;单文件库,随 volume 持久化 |
| ORM | 不引入,services 内手写参数化 SQL | 规模小,避免多一层抽象;SQL 可审查 |
| 模板 | EJS 服务端渲染骨架 | 页面可直接输出后端数据(「混入后台调用」的另一半),渐进增强 |
| 校验 | zod | 路由层统一输入校验 |
| 会话 | express-session(默认 MemoryStore) | 单实例部署,无需外部存储 |
| MCP | @modelcontextprotocol/sdk(Streamable HTTP) |
挂在 /mcp,只读,复用 service 层 |
| 测试 | node:test + 临时 DuckDB 文件 | 零额外测试框架依赖 |
2. 目录结构
awesome/
├── package.json # 依赖与 scripts(dev / start / test / migrate)
├── Dockerfile # 多阶段构建(node:22-alpine)
├── docker-compose.yml # 单服务编排 + data 卷 + healthcheck
├── .env.example # 配置样例
├── .gitignore # node_modules / data / .env
│
├── src/
│ ├── index.js # 【入口】读取配置 → 连库 → 迁移 → 起 HTTP 服务
│ ├── app.js # 【装配】Express 实例:中间件顺序 + 挂载全部路由
│ ├── config.js # 【配置】环境变量集中读取与默认值
│ │
│ ├── db/ # ── 数据层 ──
│ │ ├── connection.js # DuckDB 连接单例(含初始化与查询助手)
│ │ ├── schema.sql # 全部建表 DDL(唯一 schema 事实来源)
│ │ └── migrate.js # 启动时执行 DDL + 幂等种子数据(初始管理员等)
│ │
│ ├── routes/ # ── 接口层(薄:校验 → service → 响应)──
│ │ ├── pages.routes.js # 页面路由:GET 首页/列表/详情/登录/管理台壳
│ │ ├── entries.routes.js # /api/entries…(搜索、随机、详情、增改灰删)
│ │ ├── tags.routes.js # /api/tags…(树读取、新增、移动、删除)
│ │ ├── comments.routes.js # /api/comments…(发布、列表、删除)
│ │ ├── admin.routes.js # /api/admin…(账号 CRUD、内容源开关、AI 录入入口)
│ │ └── mcp.routes.js # /mcp 与 /healthz 的挂载转发
│ │
│ ├── services/ # ── 业务服务层(核心规则所在,唯一写 SQL 处)──
│ │ ├── entry.service.js # 条目 CRUD、状态机、核验时间
│ │ ├── tag.service.js # 标签树:增删、移动(防环)、子级上移继承
│ │ ├── comment.service.js # 评论发布/删除
│ │ ├── user.service.js # 账号与角色(admin / editor)
│ │ ├── source.service.js # 内容源注册表:human / ai / feed 三类源的配置与策略
│ │ └── search.service.js # 搜索(DuckDB FTS)、过滤组合、random_entry
│ │
│ ├── ingest/ # ── 采集层:自动化内容的来源(拉取与整理)──
│ │ ├── scheduler.js # 定时器:按每条 feed 源的 cron 计划触发 pipeline
│ │ ├── pipeline.js # 单次采集编排:fetch → 解析 → 去重 → 入库 → 运行记录
│ │ └── adapters/ # 解析适配器(可插拔,一个外部站点一个文件)
│ │ ├── rss.adapter.js # 通用 RSS/Atom(多数博客周刊直接可用)
│ │ ├── html.adapter.js # 通用网页:CSS 选择器规则配置化抽取
│ │ └── eryajf-weekly.adapter.js # 站点专用:wiki.eryajf.net 学习周刊逐期解析
│ │
│ ├── mcp/ # ── 机器读者入口 ──
│ │ ├── server.js # SDK Server + StreamableHTTPServerTransport 装配
│ │ └── tools.js # search_entries / get_entry / random_entry / list_tags
│ │ # (工具实现 = 直接调用 services,只读)
│ │
│ ├── middleware/
│ │ ├── auth.js # requireLogin / requireRole('admin');AI Key 校验
│ │ └── errors.js # 404 兜底 + 统一错误处理器 + 请求日志
│ │
│ └── views/ # ── 视图(由原型平移改造)──
│ ├── layouts/base.ejs # 公共 <head>、顶栏、页脚骨架
│ ├── index.ejs # 首页 ← design/prototype/index.html
│ ├── list.ejs # 列表页 ← list.html
│ ├── detail.ejs # 详情页 ← detail.html
│ ├── login.ejs # 登录页 ← login.html
│ └── admin/ # 管理台各分区 ← admin.html 拆分
│ ├── accounts.ejs tags.ejs content.ejs comments.ejs sources.ejs
│
├── public/ # ── 静态资源(Express.static 直出)──
│ ├── css/main.css # 设计系统 ← design/prototype/assets/css/main.css
│ ├── js/main.js # 通用交互 ← assets/js/main.js(去演示逻辑)
│ ├── js/admin.js # 管理台交互(分区切换、表格操作)
│ └── favicon.svg
│
├── data/ # DuckDB 库文件目录(docker volume 挂载点,不入库)
│
└── test/
├── helpers.js # 构建临时库 + 生成 app 实例的工具
├── tags.test.js # 标签树移动/删除规则
├── entries.test.js # 状态机与搜索
└── mcp.test.js # 四个工具冒烟测试
3. 模块职责
3.1 入口与装配 —— src/index.js、src/app.js
- 负责:启动顺序编排(config → db.connect → migrate → app → listen);中间件装配(静态目录、session、json 解析、路由挂载、错误处理最后注册)。
- 不做:任何业务逻辑。
3.2 配置 —— src/config.js
- 负责:集中读取环境变量并给默认值:
PORT(3000)、DUCKDB_PATH(./data/awesome.duckdb)、SESSION_SECRET、ADMIN_INIT_PASSWORD、AI_INGEST_KEY、MCP_READONLY(true)。 - 约束:任何模块不得直接读
process.env,一律 import config。
3.3 数据层 —— src/db/
- 负责:连接单例与查询助手(prepared statement 封装);
schema.sql为全库唯一 DDL 来源;migrate.js幂等(CREATE TABLE IF NOT EXISTS + 种子:初始 admin、内置「人工录入」源)。 - 不做:不含业务规则;不了解 HTTP。
3.4 接口层 —— src/routes/
- 负责:URL → handler 映射;zod 解析 query/body;调用对应 service;包装统一响应
{ ok: true, data }/{ ok: false, error: { code, message } }。 - 不做:SQL、业务判定(如「能否灰掉」属于 service)。
3.5 业务服务层 —— src/services/(核心)
| service | 核心规则 |
|---|---|
entry.service |
状态机:pending → active / greyed;active ⇄ greyed 可逆;删除仅物理删除且需 admin。写入时维护 updated_at、人工改动刷新 verified_at |
tag.service |
树操作:新增子标签;移动时校验目标不是自身后代(防环);删除父标签时子标签自动上移一级;sort 权重排序 |
comment.service |
登录或匿名昵称均可发布;删除为物理删除(admin 或本人) |
user.service |
角色 admin / editor;editor 不能管理账号与内容源 |
source.service |
内容源注册:三类来源——human(人工录入,常开)/ ai(AI 接口,可停用)/ feed(外部站点自动拉取:URL、适配器名、cron 计划);策略字段:AI/Feed 新条目默认落 pending,「直发」开关关闭时才直接 active;记录每源的最近运行状态 |
search.service |
组合过滤(关键字 + 类型 + 标签集合,标签含后代);基于 DuckDB FTS 的全文索引;random_entry() 从 active 集合随机 |
3.6 采集层 —— src/ingest/(自动化内容的来源)
内容源不只是「人工 / AI 接口」两种手动通道,还包含从外部站点定时拉取并整理的
自动通道。首个落地来源:二丫讲梵学习周刊 https://wiki.eryajf.net/weekly/
(VuePress 站点,自带 /rss.xml;每期页面内含若干开源项目条目)。
- 负责:
scheduler.js:进程内定时器,扫描 enabled 的 feed 源,到达各自 cron 计划即触发一次采集(单实例内互斥锁防重入);pipeline.js:单次采集编排——adapter.fetch(url)产出原始条目数组{ title, url, description, extra }→ 规范化 → 按规范化 URL 去重(已存在即 skip)→ 经entry.service.create()入库(状态按源策略:默认pending待人工核验)→ 写一条运行记录;adapters/:解析器可插拔。优先写通用rss.adapter.js(覆盖大多数周刊/博客); RSS 拿不到结构化项目列表时用站点专用适配器,如eryajf-weekly.adapter.js(拉取最新一期 HTML,抽取期号、每期内的项目名/仓库链接/推荐语);- 失败不抛出中断进程:错误记入运行记录并在管理台可见。
- 不做:不做 UI 判断、不直接写 SQL(经 services);不改已存在条目(更新由人工在管理台完成)。
3.7 MCP 模块 —— src/mcp/
- 负责:把四个只读工具暴露为 MCP Streamable HTTP(
POST/GET/DELETE /mcp):search_entries、get_entry、random_entry、list_tags;实现即一行 service 调用,保证人看的 API 与 AI 用的 MCP 永远同源同权。 - 不做:任何写操作(登录页已声明:此门不对机器开放)。
3.8 中间件 —— src/middleware/
auth.js:session 登录态;requireRole('admin')保护/api/admin/*;X-Ingest-Key校验 AI 录入来源并标记source='ai'。errors.js:业务错误 → HTTP 状态码映射;兜底 500;简量请求日志。
3.9 视图 —— src/views/(「混入后台调用」模式)
- EJS 渲染骨架与首屏必需数据(标题、统计、首屏卡片),保证无 JS 也完整可读;
- 交互性部分(搜索联想、筛选联动、管理台表格、评论提交)由页面内
<script>直接fetch('/api/…')完成——即需求所述「直接在页面中混入后台调用」; - 原型中的演示数据与演示 toast 全部替换为真实接口调用。
3.10 静态资源 —— public/
- 设计系统 CSS 与通用 JS 从原型原样平移(这是本次设计阶段的资产);仅删除 demo 提交拦截,改为真实提交。
4. 数据模型概要(支撑模块边界)
users(id, username UNIQUE, password_hash, role, status, last_login_at)
sources(id, kind 'human'|'ai'|'feed', name, enabled,
url, adapter, cron_expr, -- feed 源专用:拉取地址 / 适配器 / 计划
api_key_hash, direct_publish, -- ai 源专用
last_run_at, last_run_status)
source_runs(id, source_id → sources.id, started_at, finished_at,
status, found, created, skipped, error) -- feed 每次采集一条运行记录
entries(id, title, slug UNIQUE, type, description_md, url, license,
stars, status 'active'|'greyed'|'pending', source_id → sources.id,
verified_at, created_at, updated_at)
tags(id, name, parent_id → tags.id, sort) -- 嵌套树
entry_tags(entry_id, tag_id) -- 多对多
comments(id, entry_id, author_name, user_id NULL, body, created_at)
audit_logs(id, actor, action, target, created_at) -- 人工改动审计
外键方向决定依赖:routes → services → db;services 之间允许单向调用 (如 entry.service 调 tag.service 校验标签存在),禁止循环。
5. 主要 HTTP 端点(与 MCP 的对应)
| 方法与路径 | 说明 | MCP 工具 |
|---|---|---|
GET /api/entries?q&type&tags&sort&page |
搜索/浏览(tags 含后代展开) | search_entries |
GET /api/entries/random |
随机一条 | random_entry |
GET /api/entries/:slug |
详情(含机读 JSON 所需全字段) | get_entry |
POST/PATCH/DELETE /api/entries… |
增改灰删(登录;AI 走 ingest key → pending) | — |
GET /api/tags/tree · POST/PATCH/DELETE /api/tags… |
标签树读写 | list_tags(读) |
POST /api/comments · DELETE /api/comments/:id |
评论 | — |
POST /api/admin/login · /api/admin/users… · /api/admin/sources… |
登录、账号、内容源(feed 源含 URL/适配器/计划配置) | — |
POST /api/admin/sources/:id/run · GET /api/admin/sources/:id/runs |
手动触发一次拉取 · 运行历史(found/created/skipped) | — |
GET /healthz |
存活检查(compose healthcheck 用) | — |
6. Docker 部署形态
services:
web:
build: .
ports: ["3000:3000"]
volumes: ["./data:/app/data"] # DuckDB 单文件持久化
environment:
SESSION_SECRET: ${SESSION_SECRET}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD}
AI_INGEST_KEY: ${AI_INGEST_KEY}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
- 镜像:两阶段构建,产物仅
node_modules(prune --omit=dev)+src+public; - 单进程单实例(MemoryStore session 与 DuckDB 文件写入都以此为前提);
- 升级 = 换镜像重启,数据留在宿主机
./data。
7. 实现里程碑(建议顺序)
- M1 地基:config / db / schema / migrate + 种子 →
GET /healthz通; - M2 核心 API:entries / tags(含树规则)/ comments 的 service 与路由 + 单测;
- M3 页面接管:views 平移原型,页面 fetch 替换演示数据(首页、列表、详情);
- M4 管理侧与内容源:auth、admin 各分区接真数据、AI ingest key 通道;
- M5 采集层:scheduler + rss.adapter 通用链路 →
eryajf-weekly.adapter.js首个站点适配器跑通「拉取 → pending → 管理台核验上架」闭环 + 运行记录; - M6 MCP:四工具挂载 + 冒烟测试;
- M7 交付:Dockerfile / compose / README。