48 KiB
CMS 化改造方案
将 raisfast 从博客系统改造为通用 CMS 平台,具备高度可扩展性, 可作为公司官网、新闻站、电商内容管理、SaaS 后端等系统的基础。
1. 设计理念
借鉴 Strapi 的 Schema-Driven 模式 + WordPress 的 Hook 优先 模式:
| 借鉴对象 | 核心思想 | 我们的做法 |
|---|---|---|
| Strapi | Schema JSON → 自动建表 → 自动生成 API → 自动生成 Admin UI | Schema TOML → 自动 Migration → 泛型 API → 插件扩展 |
| WordPress | 注册 Content Type → 自动生成 Admin 菜单/URL → Hooks 扩展一切 | 插件注册 Content Type → 自动路由 → HookPoint 泛化 |
核心原则:
- Schema 即代码:内容类型用 TOML 定义,版本控制可追踪
- 约定优于配置:注册一个 content type 自动获得 CRUD API + Admin 管理
- 插件优先:新功能优先通过插件实现,而非硬编码
- 渐进迁移:旧代码并行运行,逐步替换,不搞大爆炸重写
- 内置 + 扩展双轨制:内置基础功能(blog)降低开箱门槛,Content Type 满足扩展需求
- 插件→原生晋升路径:热门插件验证需求后,可用 Rust 原生重写为内置模块,API 不变用户无感迁移
2. 架构总览
┌──────────────────────────────────────────────────────────────┐
│ 应用层 │
│ Blog / 官网 / 新闻站 / Wiki / 电商商品管理 │
│ (每个是不同的 content_type TOML + 插件组合) │
├──────────────────────────────────────────────────────────────┤
│ CMS 框架层 │
│ ├── 内容类型引擎 (Phase 8) │
│ │ ├── Schema TOML 解析 / 校验 / 注册 │
│ │ ├── 自动 Migration 生成 │
│ │ ├── 泛型 CRUD Repository │
│ │ └── 泛型 API Handler 自动注册 │
│ ├── 动态 RBAC (Phase 9) │
│ │ ├── 角色/权限数据表 │
│ │ ├── 细粒度权限矩阵 (per content type / per field) │
│ │ └── 条件权限 (creator == $user.id) │
│ ├── 插件系统 v3 (Phase 10) │
│ │ ├── 插件注册 content type │
│ │ ├── 插件注册路由 │
│ │ ├── HookPoint 泛化 (ContentCreating::{type}) │
│ │ └── 生命周期扩展 (register/bootstrap/destroy) │
│ ├── 站点配置 (Phase 11) │
│ │ ├── Options KV 表 │
│ │ ├── 自动加载机制 │
│ │ └── 公开/私有配置分离 │
│ ├── 多租户基础 (Phase 12) │
│ │ ├── tenant_id 隔离 │
│ │ ├── 域名 → 租户解析 │
│ │ └── 租户级配置覆盖 │
│ ├── 主题/模板 (Phase 13) │
│ │ ├── Tera 模板引擎 │
│ │ ├── 主题包结构 │
│ │ └── 服务端渲染 (SSR) 可选 │
│ └── Admin Dashboard API (Phase 14) │
│ ├── Content Type Builder API │
│ ├── 角色/权限管理 API │
│ └── 仪表盘统计 API │
├──────────────────────────────────────────────────────────────┤
│ 基础设施层(已完成) │
│ ├── Axum + Tokio │
│ ├── SQLite / PostgreSQL / MySQL (多 DB 支持) │
│ ├── EventBus + Worker + Cron │
│ ├── Tantivy 全文搜索 │
│ ├── WASM / JS / Lua 三引擎插件 │
│ ├── JWT Auth + Rate Limiting │
│ ├── SSE 实时推送 │
│ └── TLS / CORS / 密码策略 │
└──────────────────────────────────────────────────────────────┘
3. Phase 8:动态内容类型系统
这是整个 CMS 化的基石。完成后,现有硬编码的 posts、categories、tags 等
都将变为 content type 定义,CRUD API 由框架自动生成。
3.1 Content Type Schema 定义
每个内容类型一个 TOML 文件,放在 content_types/ 目录下。
# content_types/post.toml
[content_type]
name = "Post" # 显示名称
singular = "post" # 单数 API 路径段
plural = "posts" # 复数 API 路径段
table = "posts" # 数据库表名
description = "博客文章"
draft_publish = true # 支持 draft/published/archived 状态
slug_field = "title" # 自动从 title 字段生成 slug
timestamps = true # 自动 created_at / updated_at
soft_delete = false # 是否软删除
# ── 字段定义 ──────────────────────────────────────────────
[fields.title]
type = "text"
required = true
max_length = 200
label = "标题"
[fields.slug]
type = "uid"
target_field = "title"
unique = true
label = "URL 标识"
[fields.content]
type = "richtext"
required = true
label = "正文"
[fields.excerpt]
type = "text"
max_length = 500
label = "摘要"
[fields.cover_image]
type = "media"
accept = ["image/*"]
max_count = 1
label = "封面图片"
[fields.status]
type = "enum"
enum_values = ["draft", "published", "archived"]
default = "draft"
label = "状态"
[fields.author]
type = "relation"
relation_type = "many_to_one"
target = "user"
label = "作者"
[fields.category]
type = "relation"
relation_type = "many_to_one"
target = "category"
label = "分类"
[fields.tags]
type = "relation"
relation_type = "many_to_many"
target = "tag"
through = "posts_tags"
label = "标签"
[fields.view_count]
type = "integer"
default = 0
private = true
label = "浏览量"
[fields.is_pinned]
type = "boolean"
default = false
label = "是否置顶"
# ── 列表视图配置 ──────────────────────────────────────────
[list_view]
default_sort = "is_pinned:desc,created_at:desc"
columns = ["title", "status", "author", "category", "created_at"]
# ── 索引配置 ──────────────────────────────────────────────
[[indexes]]
fields = ["slug"]
unique = true
[[indexes]]
fields = ["status", "created_at"]
其他 content type 示例:
# content_types/product.toml — 电商商品
[content_type]
name = "Product"
singular = "product"
plural = "products"
table = "products"
description = "商品"
draft_publish = true
slug_field = "name"
timestamps = true
[fields.name]
type = "text"
required = true
max_length = 200
[fields.price]
type = "decimal"
required = true
min = 0
[fields.inventory]
type = "integer"
default = 0
min = 0
[fields.images]
type = "media"
accept = ["image/*"]
max_count = 10
[fields.specs]
type = "json"
label = "商品规格 (JSON)"
# content_types/page.toml — 单页(公司介绍、关于我们等)
[content_type]
name = "Page"
singular = "page"
plural = "pages"
table = "pages"
description = "独立页面"
draft_publish = true
slug_field = "title"
timestamps = true
[fields.title]
type = "text"
required = true
max_length = 200
[fields.slug]
type = "uid"
target_field = "title"
unique = true
[fields.content]
type = "richtext"
required = true
[fields.layout]
type = "enum"
enum_values = ["full", "sidebar", "landing"]
default = "full"
3.2 字段类型系统
共 17 种内置字段类型,对齐 Strapi:
| 类型 | 存储映射 | 说明 | 校验规则 |
|---|---|---|---|
text |
VARCHAR / TEXT | 短文本 / 长文本 | max_length, required, unique, pattern |
richtext |
TEXT | Markdown 正文 | required |
integer |
INTEGER | 整数 | min, max, required |
bigint |
BIGINT | 大整数 | min, max, required |
decimal |
DECIMAL | 精确小数 | precision, scale, min, max |
float |
FLOAT | 浮点数 | min, max, required |
boolean |
BOOLEAN | 布尔 | default |
date |
TEXT (ISO 8601) | 日期 | required, unique |
datetime |
TEXT (ISO 8601) | 日期时间 | required, unique |
time |
TEXT | 时间 | required |
email |
VARCHAR | 邮箱 | required, unique |
password |
VARCHAR | 密码(Argon2 加密存储) | min_length, max_length |
enum |
VARCHAR | 枚举 | values, default |
uid |
VARCHAR | URL 安全标识 | target_field, unique |
json |
TEXT (JSON) | 任意 JSON | schema (可选 JSON Schema 校验) |
media |
VARCHAR (URL) | 媒体文件引用 | accept, max_count |
relation |
FK / junction | 关系 | relation_type, target, through |
字段通用属性:
| 属性 | 类型 | 说明 |
|---|---|---|
type |
string | 字段类型(必填) |
required |
bool | 是否必填 |
unique |
bool | 是否唯一 |
default |
value | 默认值 |
private |
bool | 私有字段,公开 API 隐藏,admin API 可见 |
label |
string | Admin UI 显示标签 |
description |
string | 字段说明 |
immutable |
bool | 创建后不可修改 |
六种关系类型:
| 关系 | 说明 | 示例 |
|---|---|---|
one_to_one |
一对一 | 用户 ↔ 个人资料 |
one_to_many |
一对多 | 用户 → 多篇文章 |
many_to_one |
多对一 | 多篇文章 → 一个分类 |
many_to_many |
多对多 | 文章 ↔ 标签(需 through 表) |
one_way |
单向引用 | 文章 → 推荐文章 |
many_way |
多向引用 | 无反向关系 |
3.3 项目结构
src/content_type/
├── mod.rs # ContentTypeRegistry — 注册/查询所有 content type
├── schema.rs # ContentTypeSchema / FieldSchema / FieldType / RelationConfig
├── parser.rs # TOML → ContentTypeSchema 解析器
├── migration.rs # schema → CREATE TABLE / ALTER TABLE SQL 生成器
├── repository.rs # 泛型 ContentRepository — 动态 SQL 构建
├── handler.rs # 泛型 CRUD API handler (list/get/create/update/delete)
├── validation.rs # 根据 field type 校验输入数据
├── resolver.rs # relation 字段解析(JOIN / 子查询 / 延迟加载)
├── slug.rs # UID 字段自动生成
└── search.rs # content type 与 Tantivy 搜索引擎集成
3.4 核心数据结构
/// 内容类型定义
struct ContentTypeSchema {
name: String,
singular: String,
plural: String,
table: String,
description: String,
fields: Vec<FieldSchema>,
draft_publish: bool,
slug_field: Option<String>,
timestamps: bool,
soft_delete: bool,
indexes: Vec<IndexDef>,
list_view: Option<ListViewConfig>,
}
/// 字段定义
struct FieldSchema {
name: String,
field_type: FieldType,
required: bool,
unique: bool,
default: Option<serde_json::Value>,
private: bool,
immutable: bool,
label: Option<String>,
description: Option<String>,
max_length: Option<usize>,
min: Option<f64>,
max: Option<f64>,
pattern: Option<String>,
relation: Option<RelationConfig>,
media_config: Option<MediaConfig>,
enum_values: Option<Vec<String>>,
}
/// 字段类型枚举
enum FieldType {
Text,
RichText,
Integer,
BigInt,
Decimal,
Float,
Boolean,
Date,
DateTime,
Time,
Email,
Password,
Enum,
Uid,
Json,
Media,
Relation,
}
/// 关系配置
struct RelationConfig {
relation_type: RelationType,
target: String, // 目标 content type 名称
through: Option<String>, // 多对多中间表
foreign_key: Option<String>,
}
/// 内容类型注册表
struct ContentTypeRegistry {
types: HashMap<String, ContentTypeSchema>,
}
impl ContentTypeRegistry {
/// 从 content_types/ 目录加载所有 TOML 定义
fn load_from_dir(dir: &Path) -> AppResult<Self>;
/// 注册单个 content type
fn register(&mut self, schema: ContentTypeSchema);
/// 按名称查询
fn get(&self, name: &str) -> Option<&ContentTypeSchema>;
/// 按表名查询
fn get_by_table(&self, table: &str) -> Option<&ContentTypeSchema>;
/// 获取所有已注册 content type
fn all(&self) -> Vec<&ContentTypeSchema>;
}
3.5 泛型 Content Repository
替代现有的 PostRepository、CategoryRepository 等硬编码实现:
struct ContentRepository {
pool: Pool,
registry: Arc<ContentTypeRegistry>,
}
/// 通用查询参数
struct ContentQuery {
page: i64,
page_size: i64,
sort: Option<String>, // "created_at:desc,title:asc"
filters: Vec<Filter>, // 字段级过滤
status: Option<String>, // draft / published / archived
search: Option<String>, // 全文搜索关键词
fields: Option<Vec<String>>, // 选择字段
include_relations: Vec<String>, // 要解析的 relation 字段
}
struct Filter {
field: String,
operator: FilterOp, // eq, ne, gt, gte, lt, lte, in, like, is_null
value: serde_json::Value,
}
impl ContentRepository {
/// 动态构建 SELECT 查询
async fn find(
&self,
ct: &ContentTypeSchema,
query: ContentQuery,
) -> AppResult<(Vec<serde_json::Value>, i64)> {
// 1. 构建 SELECT 子句:过滤 private 字段
// 2. 构建 WHERE 子句:filters + status
// 3. 构建 ORDER BY 子句
// 4. 构建 LIMIT/OFFSET
// 5. COUNT(*) 总数
// 6. 执行查询,映射为 JSON
}
/// 按 ID 查找
async fn find_by_id(
&self,
ct: &ContentTypeSchema,
id: &str,
) -> AppResult<Option<serde_json::Value>>;
/// 按 slug 查找
async fn find_by_slug(
&self,
ct: &ContentTypeSchema,
slug: &str,
) -> AppResult<Option<serde_json::Value>>;
/// 创建
async fn create(
&self,
ct: &ContentTypeSchema,
data: serde_json::Value,
) -> AppResult<serde_json::Value> {
// 1. 校验 required 字段
// 2. 生成 slug (如果 slug_field 存在)
// 3. 设置 timestamps
// 4. 设置默认值
// 5. 构建 INSERT SQL
// 6. 处理 relation (junction table 写入)
}
/// 更新
async fn update(
&self,
ct: &ContentTypeSchema,
id: &str,
data: serde_json::Value,
) -> AppResult<serde_json::Value>;
/// 删除
async fn delete(
&self,
ct: &ContentTypeSchema,
id: &str,
) -> AppResult<()>;
/// 批量操作
async fn batch_delete(&self, ct: &ContentTypeSchema, ids: &[String]) -> AppResult<u64>;
async fn batch_update_status(&self, ct: &ContentTypeSchema, ids: &[String], status: &str) -> AppResult<u64>;
}
3.6 泛型 API Handler
注册一个 content type 后自动生成完整 REST API:
/// 为所有 content type 自动注册路由
fn register_content_routes(router: Router, registry: &ContentTypeRegistry, state: AppState) -> Router {
let mut api = router;
for ct in registry.all() {
let plural = ct.plural.clone();
// 公开 API
api = api
.route(
&format!("/api/v1/{plural}"),
get(generic_list).post(generic_create),
)
.route(
&format!("/api/v1/{plural}/:slug"),
get(generic_get).put(generic_update).delete(generic_delete),
);
// Admin API(含所有状态)
api = api
.route(
&format!("/api/v1/admin/{plural}"),
get(generic_admin_list),
)
.route(
&format!("/api/v1/admin/{plural}/:slug"),
get(generic_admin_get),
);
}
api
}
自动生成的 API 端点示例(以 posts 为例):
| Method | Path | 说明 | 认证 |
|---|---|---|---|
| GET | /api/v1/posts |
文章列表(仅 published) | 否 |
| GET | /api/v1/posts/:slug |
文章详情 | 否 |
| POST | /api/v1/posts |
创建文章 | 是 |
| PUT | /api/v1/posts/:slug |
更新文章 | 是 |
| DELETE | /api/v1/posts/:slug |
删除文章 | 是 |
| GET | /api/v1/admin/posts |
全状态列表 | 是 (admin) |
| GET | /api/v1/admin/posts/:slug |
全状态详情 | 是 (admin) |
查询参数(所有 content type 通用):
GET /api/v1/posts
?page=1
&page_size=20
&sort=created_at:desc
&filters[status]=published
&filters[category]=uuid-xxx
&filters[tags][in]=uuid-1,uuid-2
&search=关键词
&fields=title,slug,excerpt
&include=author,category,tags
3.7 自动 Migration 生成
struct SchemaMigrator {
pool: Pool,
registry: Arc<ContentTypeRegistry>,
}
impl SchemaMigrator {
/// 根据 content type 定义生成 CREATE TABLE SQL
fn generate_create_table(ct: &ContentTypeSchema) -> String {
let mut sql = format!("CREATE TABLE IF NOT EXISTS {} (\n", ct.table);
// id TEXT PRIMARY KEY
sql.push_str(" id TEXT PRIMARY KEY,\n");
// 遍历字段,生成列定义
for field in &ct.fields {
let col_type = match field.field_type {
FieldType::Text | FieldType::RichText | FieldType::Email => "TEXT",
FieldType::Integer | FieldType::BigInt => "INTEGER",
// ...
};
sql.push_str(&format!(" {} {}", field.name, col_type));
if field.required { sql.push_str(" NOT NULL"); }
if let Some(ref default) = field.default { sql.push_str(&format!(" DEFAULT {}", default)); }
sql.push_str(",\n");
}
// 自动字段
if ct.timestamps {
sql.push_str(" created_at TEXT NOT NULL,\n");
sql.push_str(" updated_at TEXT NOT NULL,\n");
}
if ct.draft_publish {
sql.push_str(" published_at TEXT,\n");
}
if ct.soft_delete {
sql.push_str(" deleted_at TEXT,\n");
}
sql.push_str(")");
sql
}
/// 对比 schema 与现有表结构,生成 ALTER TABLE
fn generate_alter_table(ct: &ContentTypeSchema, existing_columns: &[String]) -> Vec<String>;
/// 执行 migration
async fn migrate(&self) -> AppResult<()> {
for ct in self.registry.all() {
// 检查表是否存在
// 不存在 → CREATE TABLE
// 已存在 → ALTER TABLE ADD COLUMN (仅添加,不删不改)
// 创建索引
// 创建 junction 表 (many_to_many)
}
Ok(())
}
}
3.8 现有代码迁移策略
渐进迁移,不搞大爆炸:
阶段 A:并行运行
├── content_types/post.toml 定义 Post schema
├── 新的 ContentRepository + 泛型 API 注册
└── 旧的 PostRepository + 旧 handler 保留不变
→ 新 API 走 /api/v2/posts,旧 API 走 /api/v1/posts
阶段 B:功能对齐
├── 确保泛型 API 覆盖旧 API 所有功能
├── 迁移测试
└── 旧 handler 标记 deprecated
阶段 C:切换
├── /api/v1/posts 内部转发到泛型 handler
├── 删除旧 PostRepository impl
└── 保留 PostRepository trait 作为别名
需要迁移的现有实体:
| 实体 | 现有 Repository | Content Type | 迁移难度 |
|---|---|---|---|
| post | PostRepository (13 methods) | post.toml |
中(relation 多) |
| category | CategoryRepository (5 methods) | category.toml |
低 |
| tag | TagRepository (3 methods) | tag.toml |
低 |
| comment | CommentRepository (7 methods) | comment.toml |
中(嵌套关系) |
| media | MediaRepository (4 methods) | media.toml |
低 |
| user | UserRepository (7 methods) | 系统内置,不迁移 | — |
| refresh_token | RefreshTokenRepository | 系统内置,不迁移 | — |
| plugin_storage | 无 Repository | 系统内置,不迁移 | — |
4. Phase 9:动态 RBAC 权限系统
4.1 数据库表
-- 角色
CREATE TABLE roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 权限
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
action TEXT NOT NULL,
subject TEXT NOT NULL,
fields TEXT, -- JSON: ["title","content"] 或 ["*"]
conditions TEXT, -- JSON: {"author_id": "$user.id"}
created_at TEXT NOT NULL
);
-- 预置系统角色
INSERT INTO roles (id, name, description, is_system, created_at, updated_at) VALUES
('role-admin', 'admin', '超级管理员', 1, datetime('now'), datetime('now')),
('role-editor', 'editor', '编辑', 0, datetime('now'), datetime('now')),
('role-author', 'author', '作者', 0, datetime('now'), datetime('now')),
('role-reader', 'reader', '读者', 1, datetime('now'), datetime('now'));
4.2 权限格式
权限由三部分组成:action + subject + fields + conditions
action 格式:
content-type::{type}.{operation} -- 内容操作
plugin::{name}.{action} -- 插件操作
admin::{area}.{action} -- 管理后台操作
operation 类型:
| operation | 说明 |
|---|---|
create |
创建 |
read |
读取 |
update |
更新 |
delete |
删除 |
publish |
发布(draft → published) |
archive |
归档(published → archived) |
subject 格式:
content-type::{type} -- 特定内容类型
content-type::* -- 所有内容类型
plugin::{name} -- 特定插件
admin::{area} -- 管理区域
* -- 所有
通配符:
| 格式 | 含义 |
|---|---|
content-type::post.* |
post 类型的所有操作 |
content-type::*.read |
所有类型的读取操作 |
* |
所有权限 |
conditions(条件权限):
{"author_id": "$user.id"} // 只能操作自己创建的内容
{"status": "published"} // 只能操作已发布的内容
{"organization_id": "$user.organization_id"} // 只能操作本组织的内容(多租户)
4.3 权限矩阵示例
| role | action | subject | fields | conditions |
|---|---|---|---|---|
| admin | * |
* |
* |
|
| editor | content-type::*.create |
content-type::* |
* |
|
| editor | content-type::*.read |
content-type::* |
* |
|
| editor | content-type::*.update |
content-type::* |
* |
|
| editor | content-type::*.publish |
content-type::* |
* |
|
| editor | content-type::*.delete |
content-type::* |
["*"] |
|
| author | content-type::post.create |
content-type::post |
* |
|
| author | content-type::post.read |
content-type::post |
* |
|
| author | content-type::post.update |
content-type::post |
["*"] |
{"author_id": "$user.id"} |
| author | content-type::post.delete |
content-type::post |
["*"] |
{"author_id": "$user.id"} |
| reader | content-type::post.read |
content-type::post |
["title","slug","content","excerpt"] |
|
| reader | content-type::comment.create |
content-type::comment |
["content","nickname","email"] |
4.4 权限检查中间件
/// 替代现有的 AuthUser / AdminUser / AuthorUser 硬编码提取器
struct PermissionGuard {
pub user_id: String,
pub role_id: String,
}
impl PermissionGuard {
/// 检查当前用户是否有权限执行操作
async fn check(
&self,
pool: &Pool,
action: &str,
subject: &str,
context: Option<&serde_json::Value>,
) -> AppResult<()> {
// 1. 查询 role 对应的所有 permissions
// 2. 匹配 action + subject(支持通配符)
// 3. 如果有 conditions,检查 context 是否满足
// 4. 匹配成功 → Ok(()), 否则 → Err(AppError::Forbidden)
}
}
/// 使用示例
async fn generic_create(
State(state): State<AppState>,
Path(type_name): Path<String>,
guard: PermissionGuard,
Json(data): Json<serde_json::Value>,
) -> AppResult<ApiResponse<serde_json::Value>> {
guard.check(
&state.pool,
&format!("content-type::{type_name}.create"),
&format!("content-type::{type_name}"),
None,
).await?;
// ... 执行创建
}
4.5 与现有 Auth 的兼容
现有:JWT → Claims { sub, role }
↓
role == "admin" ? OK : Forbidden (硬编码)
改造:JWT → Claims { sub, role_id }
↓
查询 permissions 表 → 匹配 action/subject → OK/Forbidden
JWT Claims 结构扩展:
struct Claims {
sub: String, // 用户 ID
role_id: String, // 角色 ID(替代原来的 role 字符串)
exp: usize,
iat: usize,
}
迁移策略:先兼容旧 token(role 字段映射到 role_id),逐步切换。
5. Phase 10:插件系统升级 (v3)
5.1 现有插件系统能力
| 能力 | 状态 |
|---|---|
| WASM / JS / Lua 三引擎 | ✅ |
| Hook: Filter / Action / StringFilter / HandleRoute | ✅ |
| 权限控制 (http / db / fs / config) | ✅ |
| VFS 虚拟文件系统 | ✅ |
| Cron 定时任务 | ✅ |
| 热重载 | ✅ |
| 自动禁用(连续错误) | ✅ |
| 指标收集 | ✅ |
5.2 需要新增的能力
| 能力 | 说明 | 优先级 |
|---|---|---|
| 插件注册 content type | 插件通过 manifest 声明自己的数据模型 | P0 |
| 插件注册路由 | 插件声明自定义 REST 端点 | P0 |
| HookPoint 泛化 | 从 Post-specific → Content-Type-generic | P0 |
| 生命周期钩子 | register → bootstrap → destroy | P1 |
| 自定义字段类型 | 插件提供新的 field type 实现 | P1 |
| 插件间通信 | 插件调用其他插件的能力 | P2 |
| Admin UI 扩展点 | 插件注册 Admin 页面/组件 | P2 |
5.3 Manifest 扩展
# plugins/ecommerce/plugin.toml
[plugin]
id = "com.example.ecommerce"
name = "E-Commerce"
version = "1.0.0"
description = "电商功能插件"
runtime = "lua"
entry = "init.lua"
[permissions]
max_memory_mb = 32
database = ["read:products", "write:products", "read:orders", "write:orders"]
http = ["payment-gateway.example.com/*"]
# ── 新增:插件声明 content types ──────────────────────────
[[content_types]]
file = "schemas/product.toml"
[[content_types]]
file = "schemas/order.toml"
# ── 新增:插件注册自定义路由 ──────────────────────────────
[[routes]]
method = "GET"
path = "/products/featured"
handler = "get_featured_products"
auth = false
[[routes]]
method = "POST"
path = "/orders"
handler = "create_order"
auth = true
permission = "plugin::ecommerce.order.create"
[[routes]]
method = "GET"
path = "/orders/:id"
handler = "get_order"
auth = true
permission = "plugin::ecommerce.order.read"
# ── 新增:插件注册 Admin 页面 ────────────────────────────
[[admin_pages]]
path = "/ecommerce/products"
label = "商品管理"
icon = "shopping-bag"
component = "admin/products"
[[admin_pages]]
path = "/ecommerce/orders"
label = "订单管理"
icon = "receipt"
component = "admin/orders"
# ── 新增:生命周期钩子 ────────────────────────────────────
[hooks.on_register]
handler = "on_register" # 插件加载时调用,注册 content type / 路由
[hooks.on_bootstrap]
handler = "on_bootstrap" # 所有插件加载完毕后调用
[hooks.on_destroy]
handler = "on_destroy" # 插件卸载时调用
5.4 HookPoint 泛化
从博客专属 hook → 通用 CMS hook:
| 现有 HookPoint | 泛化后 | 说明 |
|---|---|---|
PostCreating |
ContentCreating::post |
创建前拦截/修改 |
PostCreated |
ContentCreated::post |
创建后通知 |
PostUpdating |
ContentUpdating::post |
更新前拦截 |
PostUpdated |
ContentUpdated::post |
更新后通知 |
PostDeleted |
ContentDeleted::post |
删除后通知 |
CommentCreating |
ContentCreating::comment |
评论创建前 |
CommentCreated |
ContentCreated::comment |
评论创建后 |
RenderMarkdown |
RenderField::post.content |
字段渲染 |
FilterHtml |
FilterField::post.content |
字段过滤 |
HandleRoute |
HandleRoute |
保留 |
OnLogin |
OnLogin |
保留 |
CronTick |
CronTick |
保留 |
| — | ContentQuery::{type} |
查询前拦截/改写(新增) |
| — | FieldValidate::{type}.{field} |
自定义字段校验(新增) |
| — | ContentTransform::{type} |
输出前转换(新增) |
| — | UserRegistering |
用户注册前拦截(新增) |
| — | MediaUploading |
媒体上传前拦截(新增) |
HookPoint 实现:
enum HookPoint {
// ── 内容生命周期 ──
ContentCreating { content_type: String },
ContentCreated { content_type: String },
ContentUpdating { content_type: String },
ContentUpdated { content_type: String },
ContentDeleted { content_type: String },
ContentQuery { content_type: String },
ContentTransform { content_type: String },
// ── 字段级 ──
RenderField { content_type: String, field: String },
FilterField { content_type: String, field: String },
FieldValidate { content_type: String, field: String },
// ── 用户/认证 ──
OnLogin,
UserRegistering,
// ── 媒体 ──
MediaUploading,
// ── 路由 ──
HandleRoute,
// ── 定时任务 ──
CronTick,
}
插件 manifest hooks 配置兼容旧格式:
# 旧格式(兼容)
[hooks.on-cron-tick]
priority = 10
# 新格式(推荐)
[[hooks]]
point = "ContentCreating::post"
priority = 10
handler = "validate_post"
[[hooks]]
point = "FieldValidate::product.price"
priority = 5
handler = "validate_price"
5.5 插件注册路由实现
impl PluginManager {
/// 收集所有插件声明的路由,合并到主 Router
pub async fn collect_routes(&self) -> Vec<PluginRoute> {
let mut routes = Vec::new();
for plugin in self.plugins.read().await.values() {
if let Some(manifest) = &plugin.manifest {
for route_def in &manifest.routes {
routes.push(PluginRoute {
plugin_id: plugin.id.clone(),
method: route_def.method.clone(),
path: route_def.path.clone(),
handler: route_def.handler.clone(),
auth: route_def.auth,
permission: route_def.permission.clone(),
});
}
}
}
routes
}
}
/// 动态路由 handler — 分发到对应插件的 handler 函数
async fn plugin_route_dispatcher(
State(state): State<AppState>,
Path(params): Path<HashMap<String, String>>,
req: Request,
) -> AppResult<Response> {
let route_info = state.plugin_routes.get(req.uri().path());
// 调用对应插件的 handler 函数
state.plugins.dispatch_route(route_info, req).await
}
6. Phase 11:站点配置系统
6.1 Options 表
参考 WordPress wp_options 设计:
CREATE TABLE options (
key TEXT PRIMARY KEY,
value TEXT NOT NULL, -- JSON value
autoload BOOLEAN NOT NULL DEFAULT 1, -- 启动时预加载到内存
updated_at TEXT NOT NULL
);
6.2 内置配置项
| key | 类型 | 默认值 | 说明 |
|---|---|---|---|
site_title |
string | "My Site" |
站点标题 |
site_description |
string | "" |
站点描述 |
site_url |
string | 自动 | 站点 URL |
posts_per_page |
integer | 10 |
每页条目数 |
default_role |
string | "reader" |
新用户默认角色 |
comment_moderation |
boolean | true |
评论需审核 |
comment_order |
string | "asc" |
评论排序 |
allowed_origins |
array | [] |
CORS 白名单 |
theme |
string | "default" |
当前主题 |
admin_email |
string | "" |
管理员邮箱 |
timezone |
string | "UTC" |
时区 |
date_format |
string | "%Y-%m-%d" |
日期格式 |
permalink_structure |
string | "/:year/:month/:slug" |
URL 结构 |
rss_items |
integer | 20 |
RSS 条目数 |
maintenance_mode |
boolean | false |
维护模式 |
6.3 Options Service
struct OptionsService {
cache: RwLock<HashMap<String, serde_json::Value>>,
pool: Pool,
}
impl OptionsService {
/// 启动时加载所有 autoload = true 的配置
async fn load_autoload(&self) -> AppResult<()>;
/// 获取配置(先查缓存,再查 DB)
async fn get(&self, key: &str) -> Option<serde_json::Value>;
/// 设置配置
async fn set(&self, key: &str, value: serde_json::Value) -> AppResult<()>;
/// 批量设置
async fn set_batch(&self, pairs: HashMap<String, serde_json::Value>) -> AppResult<()>;
/// 删除配置
async fn delete(&self, key: &str) -> AppResult<()>;
/// 获取所有公开配置(前端可见)
async fn get_public(&self) -> HashMap<String, serde_json::Value>;
}
6.4 API
| Method | Path | 说明 | 认证 |
|---|---|---|---|
| GET | /api/v1/options/public |
公开配置(站点标题等) | 否 |
| GET | /api/v1/admin/options |
所有配置 | 是 (admin) |
| PUT | /api/v1/admin/options |
批量更新 | 是 (admin) |
| GET | /api/v1/admin/options/:key |
获取单个配置 | 是 (admin) |
| PUT | /api/v1/admin/options/:key |
设置单个配置 | 是 (admin) |
| DELETE | /api/v1/admin/options/:key |
删除配置 | 是 (admin) |
7. Phase 12:多租户基础
7.1 数据库表
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
domain TEXT UNIQUE, -- blog.example.com
config TEXT NOT NULL DEFAULT '{}', -- 租户级 JSON 配置覆盖
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
7.2 租户隔离策略
方案 A(推荐初期):共享数据库 + tenant_id 列
-- content_entries 统一表(替代每个 content type 一张表)
CREATE TABLE content_entries (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
content_type TEXT NOT NULL, -- "post", "product", "page" ...
data TEXT NOT NULL, -- JSON (所有字段值)
status TEXT NOT NULL DEFAULT 'draft',
slug TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
published_at TEXT,
UNIQUE(tenant_id, content_type, slug)
);
CREATE INDEX idx_entries_tenant_type ON content_entries(tenant_id, content_type);
CREATE INDEX idx_entries_tenant_status ON content_entries(tenant_id, content_type, status);
方案 B(后期可升级):独立 schema(PostgreSQL)/ 独立数据库
7.3 请求级租户解析
/// 中间件:根据请求解析当前租户
async fn tenant_middleware(
mut req: Request,
next: Next,
) -> Response {
let host = req.headers()
.get("host")
.and_then(|v| v.to_str().ok())
.unwrap_or("default");
// 从 AppState 的 tenant_cache 查找
if let Some(tenant) = resolve_tenant(host).await {
req.extensions_mut().insert(tenant);
}
next.run(req).await
}
7.4 租户级配置覆盖
// tenant.config 覆盖全局 options
{
"site_title": "Company Blog",
"theme": "corporate",
"posts_per_page": 15
}
配置优先级:租户配置 > 全局 options > 默认值
8. Phase 13:主题/模板系统
8.1 主题包结构
themes/
├── default/
│ ├── theme.toml # 主题元数据
│ ├── templates/
│ │ ├── layout.html # 基础布局
│ │ ├── index.html # 首页(内容列表)
│ │ ├── detail.html # 内容详情
│ │ ├── category.html # 分类页
│ │ ├── tag.html # 标签页
│ │ ├── archive.html # 归档页
│ │ ├── page.html # 独立页面
│ │ ├── search.html # 搜索结果页
│ │ └── 404.html # 404 页面
│ ├── partials/
│ │ ├── header.html # 页头
│ │ ├── footer.html # 页脚
│ │ ├── sidebar.html # 侧边栏
│ │ ├── pagination.html # 分页
│ │ └── comment.html # 评论区
│ ├── assets/
│ │ ├── css/
│ │ │ └── style.css
│ │ ├── js/
│ │ │ └── main.js
│ │ └── images/
│ └── functions.lua # 主题级逻辑(可选)
└── corporate/
├── theme.toml
└── ...
8.2 主题元数据
# themes/default/theme.toml
[theme]
name = "Default"
version = "1.0.0"
description = "默认博客主题"
author = "raisfast"
license = "MIT"
[theme.supports]
content_types = ["post", "page", "category", "tag"]
features = ["search", "rss", "comments", "pagination"]
[theme.settings]
[theme.settings.color_scheme]
type = "enum"
enum_values = ["light", "dark", "auto"]
default = "auto"
label = "配色方案"
[theme.settings.accent_color]
type = "text"
default = "#3b82f6"
label = "主题色"
8.3 模板引擎
选用 Tera(Rust 的 Jinja2 模板引擎):
- 运行时动态加载模板(CMS 必需)
- 继承 / include / macro 支持
- 内置过滤器丰富
- 性能优秀
use tera::Tera;
struct ThemeEngine {
tera: Tera,
theme_name: String,
}
impl ThemeEngine {
/// 加载主题模板
fn load(theme_dir: &Path) -> AppResult<Self>;
/// 渲染模板
fn render(&self, template: &str, context: &tera::Context) -> AppResult<String>;
}
8.4 SSR 渲染端点
GET / → themes/default/templates/index.html
GET /posts/:slug → themes/default/templates/detail.html
GET /category/:slug → themes/default/templates/category.html
GET /page/:slug → themes/default/templates/page.html
GET /search?q=keyword → themes/default/templates/search.html
当请求 Accept: text/html 时走 SSR,Accept: application/json 时走 API。
8.5 Headless 模式
主题系统可选。不配置 theme 时,系统为纯 Headless CMS, 所有交互通过 REST API,前端完全由 Next.js 或其他 SPA 驱动。
9. Phase 14:Admin Dashboard API
为管理后台前端提供完整的 CMS 管理 API。
9.1 Content Type Builder API
| Method | Path | 说明 |
|---|---|---|
| GET | /api/v1/admin/content-types |
列出所有 content type 定义 |
| GET | /api/v1/admin/content-types/:name |
获取单个 content type schema |
| POST | /api/v1/admin/content-types |
创建新 content type |
| PUT | /api/v1/admin/content-types/:name |
修改 content type 字段 |
| DELETE | /api/v1/admin/content-types/:name |
删除 content type |
创建 content type 示例:
POST /api/v1/admin/content-types
{
"name": "Product",
"singular": "product",
"plural": "products",
"table": "products",
"description": "商品",
"draft_publish": true,
"slug_field": "name",
"timestamps": true,
"fields": [
{"name": "name", "type": "text", "required": true, "max_length": 200},
{"name": "price", "type": "decimal", "required": true, "min": 0},
{"name": "description", "type": "richtext"},
{"name": "images", "type": "media", "accept": ["image/*"], "max_count": 10}
]
}
操作结果:
- 生成
content_types/product.toml - 自动执行
CREATE TABLE products (...)migration - 自动注册
/api/v1/productsCRUD 路由 - 自动注册 Admin 管理页面
9.2 角色/权限管理 API
| Method | Path | 说明 |
|---|---|---|
| GET | /api/v1/admin/roles |
列出所有角色 |
| POST | /api/v1/admin/roles |
创建角色 |
| PUT | /api/v1/admin/roles/:id |
更新角色 |
| DELETE | /api/v1/admin/roles/:id |
删除角色 |
| GET | /api/v1/admin/roles/:id/permissions |
获取角色权限 |
| PUT | /api/v1/admin/roles/:id/permissions |
设置角色权限 |
9.3 仪表盘统计 API
| Method | Path | 说明 |
|---|---|---|
| GET | /api/v1/admin/stats |
总览统计 |
| GET | /api/v1/admin/stats/content/:type |
内容类型统计 |
| GET | /api/v1/admin/stats/trends |
趋势数据(近 N 天) |
GET /api/v1/admin/stats
{
"total_posts": 156,
"total_comments": 423,
"total_users": 89,
"total_media": 234,
"content_by_type": {
"post": 156,
"page": 12,
"product": 45
},
"recent_activity": [
{"type": "post.created", "title": "...", "at": "..."},
{"type": "comment.created", "content": "...", "at": "..."}
]
}
10. 新增依赖
| crate | 版本 | 用途 | Phase |
|---|---|---|---|
tera |
1.x | 模板引擎(Jinja2 语法) | 13 |
toml |
0.8 | Content Type schema 解析(已有) | 8 |
jsonschema |
可选 | JSON 字段校验 | 8 |
无需引入重量级新依赖,现有 serde_json + sqlx 足以支撑大部分功能。
11. 实施路线图
Phase 8 ██████████████████████████ 动态内容类型系统 2-3 周
Phase 9 ████████████████ 动态 RBAC 1-2 周
Phase 10 ██████████████ 插件系统 v3 1-2 周
Phase 11 ████████ 站点配置系统 3-5 天
Phase 12 ██████ 多租户基础 1 周
Phase 13 ██████████ 主题/模板系统 1-2 周
Phase 14 ████████ Admin Dashboard API 1 周
总计约 8-12 周
依赖关系:
Phase 8 (内容类型)
├── Phase 9 (RBAC 依赖 content type 名称作为 subject)
├── Phase 10 (插件注册 content type / 泛化 hook)
├── Phase 12 (多租户依赖 content_entries 表)
├── Phase 13 (主题渲染依赖 content type 数据)
└── Phase 14 (Admin API 依赖 content type CRUD)
Phase 11 (站点配置) — 独立,可随时插入
建议顺序:
Week 1-3: Phase 8 (内容类型系统) ← 最高优先级
Week 3-4: Phase 11 (站点配置) ← 独立,穿插做
Week 4-5: Phase 9 (动态 RBAC) ← 依赖 Phase 8
Week 5-6: Phase 10 (插件 v3) ← 依赖 Phase 8
Week 7: Phase 14 (Admin API) ← 依赖 8+9
Week 8: Phase 12 (多租户) ← 可选
Week 9-10: Phase 13 (主题系统) ← 可选
12. 关键决策记录
| 决策 | 选项 A | 选项 B | 结论 | 理由 |
|---|---|---|---|---|
| Schema 存储 | TOML 文件 | 数据库 JSON | TOML 文件为主,DB 缓存 | 版本控制可追踪,可 code review |
| 通用查询 | 动态拼 SQL | 预编译每种类型 | 动态拼 SQL | 已有 db::dialect::translate 跨 DB 层 |
| 主题引擎 | Tera | Askama | Tera | 运行时动态加载,CMS 必需 |
| 前端 Admin | 扩展现有 Next.js | 独立 Admin SPA | 扩展现有 Next.js | 减少维护成本 |
| 现有代码 | 全部重写 | 逐步迁移 | 逐步迁移,新旧并行 | 降低风险 |
| 多租户 | 共享 DB + tenant_id | 独立 DB | 初期共享 DB | 部署简单 |
| 模板渲染 | 可选 SSR | 纯 Headless | 都支持 | Headless 模式是默认,SSR 可选 |
13. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 动态 SQL 性能低于硬编码 | 中 | 提供预编译选项,热点 content type 可退回硬编码 |
| 泛型 API 无法覆盖特殊需求 | 中 | HookPoint 机制允许插件拦截和改写任何阶段 |
| TOML schema 变更频繁导致 migration 复杂 | 中 | 只支持 ADD COLUMN,不支持 DROP/RENAME,需要手动处理 |
| 多租户查询性能 | 低 | 初期不做多租户,预留 tenant_id 字段即可 |
| 主题安全(模板注入) | 中 | Tera 默认自动转义,禁用危险过滤器 |
| Phase 8 工作量大导致阻塞后续 | 高 | 先实现核心 5 种字段类型,其余渐进添加 |
14. 验收标准
Phase 8 验收
content_types/post.toml定义完整,可解析- 启动时自动创建/更新
posts表 /api/v1/postsCRUD 全部通过泛型 handler 工作- 现有 439 个测试不回归
- 新增 content type 只需创建 TOML 文件 + 重启
Phase 9 验收
roles/permissions表创建- 管理员可通过 API 创建角色并分配权限
PermissionGuard替代硬编码角色检查- 条件权限(
author_id == $user.id)生效
Phase 10 验收
- 插件可通过 manifest 声明 content type
- 插件可通过 manifest 声明路由
- HookPoint 泛化后旧插件兼容运行
site-maintenance插件无需修改即可运行
Phase 11 验收
options表 + autoload 机制工作/api/v1/options/public返回站点标题等公开配置- Admin 可通过 API 修改所有配置
Phase 12 验收
tenants表 +content_entries表创建- 请求级租户解析中间件工作
- 数据按 tenant_id 隔离,跨租户不可见
Phase 13 验收
- 默认主题
themes/default/渲染正常 - 切换主题只需改
options.theme配置 - Headless 模式不受影响
Phase 14 验收
- Content Type Builder API 可创建新 content type
- 角色/权限管理 API 完整
- 仪表盘统计 API 返回正确数据