Files
raisfast/docs/cms-roadmap.md
T

48 KiB
Raw Blame History

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 化的基石。完成后,现有硬编码的 postscategoriestags 等 都将变为 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

替代现有的 PostRepositoryCategoryRepository 等硬编码实现:

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,
}

迁移策略:先兼容旧 tokenrole 字段映射到 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 模板引擎

选用 TeraRust 的 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 时走 SSRAccept: application/json 时走 API。

8.5 Headless 模式

主题系统可选。不配置 theme 时,系统为纯 Headless CMS 所有交互通过 REST API,前端完全由 Next.js 或其他 SPA 驱动。


9. Phase 14Admin 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}
    ]
}

操作结果:

  1. 生成 content_types/product.toml
  2. 自动执行 CREATE TABLE products (...) migration
  3. 自动注册 /api/v1/products CRUD 路由
  4. 自动注册 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/posts CRUD 全部通过泛型 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 返回正确数据