awesome/docs/ARCHITECTURE.md
cheney a5c5d24083
All checks were successful
Build and Deploy / build (push) Successful in 12s
Build and Deploy / deploy (push) Successful in 26s
refactor: 彻底移除 docker compose,改用纯 docker 构建+重启部署
2026-09-01 10:27:11 +08:00

271 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Awesome Index · 框架设计
> 目标:把 `design/prototype/` 的静态原型落地为一个由 DuckDB 驱动、全 JavaScript 编写、
> 对人与对 AIMCP同时提供服务、可 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 # 依赖与 scriptsdev / 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 → dbservices 之间允许单向调用
(如 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。