271 lines
18 KiB
Markdown
271 lines
18 KiB
Markdown
# 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-bookworm-slim)
|
||
├── start.sh # 用已构建镜像重启容器(docker run,无 compose)
|
||
├── .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 部署形态
|
||
|
||
```yaml
|
||
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. 实现里程碑(建议顺序)
|
||
|
||
1. **M1 地基**:config / db / schema / migrate + 种子 → `GET /healthz` 通;
|
||
2. **M2 核心 API**:entries / tags(含树规则)/ comments 的 service 与路由 + 单测;
|
||
3. **M3 页面接管**:views 平移原型,页面 fetch 替换演示数据(首页、列表、详情);
|
||
4. **M4 管理侧与内容源**:auth、admin 各分区接真数据、AI ingest key 通道;
|
||
5. **M5 采集层**:scheduler + rss.adapter 通用链路 → `eryajf-weekly.adapter.js`
|
||
首个站点适配器跑通「拉取 → pending → 管理台核验上架」闭环 + 运行记录;
|
||
6. **M6 MCP**:四工具挂载 + 冒烟测试;
|
||
7. **M7 交付**:Dockerfile / compose / README。
|