[开发]FlintHub CMS 资讯插件 — 完整开发计划(定稿)

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:02:36

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/docs/cms插件开发计划.md。

FlintHub CMS 资讯插件 — 完整开发计划(定稿)

版本:1.0.0(2026-09-01)

依据:用户需求《FlintHub CMS资讯插件 完整开发规划方案(含系统设置 最终定稿)》+ 插件开发规范.md

参考实现:plugins/qa(后台 UI 对齐)、plugins/announcements(单页/独立库写法)、plugins/points_mall、plugins/content_review

本文件是实施蓝图:先做结构 + 视图骨架(预留 PHP 业务逻辑位置),再逐模块填充业务逻辑。


0. 插件命名与定位

项值
目录名 / 插件名cms(小写下划线,一目录一插件)
命名空间Plugin\Cms(首字母大写驼峰,与目录自动加载器映射一致)
显示名CMS资讯
图标newspaper(已在 TemplateCompiler::$map 中,无需新增)
后台入口plugin.json 的 admin_url: "/admin/cms"(不注册 admin_sidebar_links,新插件约定)
数据库混合架构:小表(设置/栏目/单页)→ 插件私有 plugins/cms/data/cms.sqlite;内容表(新闻/图文)→ SplitDB 分片(plugins/cms/data/splitdb/:cms_index 索引 + cms_item 桶 + extern 正文),支撑大数据量测试
技术栈PHP + htmx + Alpine.js(不引入新框架)
沙盒化100% 插件内闭环:不改核心代码、不写核心库表、不改 admin.css;卸载 = 删目录 + uninstall() 清表

三大内容模块 + 系统设置:新闻(news)、图文(gallery)、单页(page)、系统设置(settings)。

双前台主题:资讯新闻主题(theme_news)/ 企业官网主题(theme_corp),后台一键切换,业务逻辑共用、仅视图不同。


1. 完整目录结构

plugins/cms/
├── plugin.json                  # 插件配置(含 hooks 清单)
├── Plugin.php                   # 主类:db/ddl/activate/deactivate/uninstall + 全部业务数据方法
├── AdminController.php          # 后台控制器(5 大模块)
├── FrontController.php          # 前台控制器(双主题分发 + SEO + 浏览量)
├── hook/
│   ├── init_after.php           # 惰性建表兜底(幂等)
│   ├── route_register.php       # 前台路由注册
│   └── admin_route_register.php # 后台路由注册
├── views/
│   ├── admin/                   # 后台视图(对齐 qa 后台 UI)
│   │   ├── _tabs.php            # 5 模块 tab 导航(公共 partial)
│   │   ├── settings.php         # 系统设置
│   │   ├── categories.php       # 栏目管理(列表 + 两级缩进)
│   │   ├── categories_form.php  # 栏目添加/编辑(弹窗或内嵌表单)
│   │   ├── news.php             # 新闻管理列表
│   │   ├── content_form.php     # 新闻/图文共用 添加/编辑表单(type 区分)
│   │   ├── gallery.php          # 图文管理列表
│   │   ├── pages.php            # 单页管理列表
│   │   └── page_form.php        # 单页添加/编辑表单
│   ├── theme_news/              # 主题1:资讯新闻主题(独立完整页面,viewRaw 渲染)
│   │   ├── layout.php           # 页面骨架:<head>(SEO/主题CSS) + 顶部栏 + 内容区 + 页脚
│   │   ├── index.php            # 首页(侧边栏目导航 + 主内容列表流)
│   │   ├── category.php         # 栏目列表页(分页)
│   │   ├── article.php          # 新闻详情
│   │   ├── gallery.php          # 图文详情(图集展示)
│   │   ├── page.php             # 单页
│   │   └── _partials/           # 面包屑 / 分页 / 列表项 / 侧栏 公共片段
│   └── theme_corp/              # 主题2:企业官网主题(结构同上)
│       ├── layout.php           # 顶部全局导航 + 区块化首页布局
│       ├── index.php            # 首页(hero + 产品图文区块 + 新闻区块 + 关于/联系区块)
│       ├── category.php
│       ├── article.php
│       ├── gallery.php
│       ├── page.php
│       └── _partials/
├── assets/
│   ├── style.css                # 公共层(复用 --mn-* 变量,卡片/按钮/列表/表单)
│   ├── script.js                # 公共脚本(htmx 初始化、上传交互、确认弹窗,零内联)
│   ├── theme-news.css           # 资讯新闻主题独立样式
│   └── theme-corp.css           # 企业官网主题独立样式
├── lang/
│   ├── zh.php                   # 简体(必填)
│   ├── en.php                   # 英文(同步)
│   └── zh_tw.php                # 繁体(同步)
├── data/                        # 运行时自动创建(Plugin::db 自动补 .htaccess/web.config)
│   ├── cms.sqlite               # 小表独立库:settings / categories / pages(原生 PDO)
│   └── splitdb/                 # ★ 内容表 SplitDB 分片根(大数据量方案,$dataPath 指向此处)
│       ├── meta/
│       │   ├── main_index.sqlite  # cms_index 索引表(列表/分页走它,天然有序 LIMIT)
│       │   └── global_id.sqlite   # 内容 ID 全局取号(IDGenerator)
│       ├── bucket/active/{YYYYQn}/{桶}.sqlite  # cms_item 桶真相源(季度分区 + 哈希分桶)
│       └── extern/{YYYY}/{Qn}/{桶}/{id}.txt    # 正文外置文件(ExternStorage 原子写)
└── 说明.md                       # 插件使用说明

禁止:.bak/.revert/.tmp 残留;插件目录外写文件;修改核心 app/、assets/css/admin.css。


2. plugin.json(定稿)

{
    "name": "CMS资讯",
    "version": "1.0.0",
    "author": "FlintHub",
    "description": "CMS资讯:新闻/图文/单页三大内容模块 + 系统设置 + 双主题一键切换,适配企业站与资讯站",
    "activated": false,
    "category": "content",
    "icon": "newspaper",
    "admin_url": "/admin/cms",
    "hooks": {
        "init_after": "hook/init_after.php",
        "route_register": "hook/route_register.php",
        "admin_route_register": "hook/admin_route_register.php"
    },
    "permissions": []
}

要点:

  • 钩子路径用 / 分隔;本插件只需 3 个钩子(建表兜底 + 前后台路由)。
  • 注册/改动 hooks 后必须刷新插件缓存:删 plugins/plugins_cache.json 或 \app\Helpers\Plugin::refresh(),否则钩子不生效。
  • activated 由系统管理,插件代码不手改。
  • 首版不注册 admin_sidebar_links / nav_*(后台经插件管理页 admin_url 进入;前台 CMS 为独立站点入口,导航栏挂载作为可选增强,见 §12 后续项)。

3. 数据库设计(定稿:混合架构 — 小表单库 + 内容表 SplitDB 分片)

架构总览:本插件存储分两层——

1. 小表单库 plugins/cms/data/cms.sqlite(\app\Helpers\Plugin::db('cms') 原生 PDO):cms_settings / cms_categories / cms_pages 三张小表(量小、低频、无需分片),见 §3.1/3.2/3.4;

2. 内容表 SplitDB 分片(大数据量测试核心):新闻/图文内容走 plugins/cms/data/splitdb/ 分片体系(cms_index 索引表 + cms_item 桶表 + extern 正文外置 + global_id 取号),见 §3.3。

分片根 $root = __DIR__ . '/data/splitdb',所有 Schema::mainIndexDb($root) / bucketDb($quarter,$bucket,$root) / bucketFromPath($path,$root) / ExternStorage::write($root,...) 调用都显式传 $root,与核心库(全局 data/)完全隔离、零冲突。

DDL 全为 SQLite 语法(INTEGER PRIMARY KEY / TEXT / 秒级时间戳 INTEGER),Plugin::ensureSchema / CREATE TABLE IF NOT EXISTS 幂等执行。

3.1 cms_settings — 系统设置(key/value)

CREATE TABLE IF NOT EXISTS cms_settings (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL DEFAULT ''
);

预置键(Plugin::activate() 中 INSERT OR IGNORE 默认值):

key默认值说明
themenews前台主题:news=资讯新闻 / corp=企业官网
site_nameCMS站点网站名称(可与主站不同)
site_subtitle专注优质内容网站副标题
site_footer© 2026 CMS站点 版权所有前台页脚版权文案
news_enabled1新闻模块前台展示开关
gallery_enabled1图文模块前台展示开关
list_show_date1列表页显示发布时间
list_show_thumb1列表页显示缩略图
views_enabled1内容浏览统计开关(简易字段计数)
seo_title_prefix``全站默认 SEO 标题前缀
seo_keywords``全站默认关键词
seo_description``全站默认描述
page_size10前台列表每页条数

3.2 cms_categories — 栏目(严格两级)

CREATE TABLE IF NOT EXISTS cms_categories (
    id         INTEGER PRIMARY KEY,
    parent_id  INTEGER NOT NULL DEFAULT 0,   -- 0=顶级栏目;仅顶级可拥有子栏目(二级)
    name       TEXT NOT NULL,
    slug       TEXT NOT NULL DEFAULT '',     -- 唯一,用于前台 URL(可空则用 id)
    type       TEXT NOT NULL DEFAULT 'news', -- news / gallery(决定默认内容类型)
    sort       INTEGER NOT NULL DEFAULT 0,   -- 升序
    enabled    INTEGER NOT NULL DEFAULT 1,
    seo_title  TEXT NOT NULL DEFAULT '',
    seo_keywords TEXT NOT NULL DEFAULT '',
    seo_description TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cms_categories_slug ON cms_categories (slug);
CREATE INDEX IF NOT EXISTS idx_cms_categories_parent ON cms_categories (parent_id, sort);

约束(控制器层校验,非 DB 层):

  • parent_id 只能是 0 或某个顶级栏目的 id;禁止给二级栏目挂子栏目(防第三级)。
  • 删除栏目:有子栏目或有关联内容 → 拒绝并提示先处理。

3.3 内容表 SplitDB 分片(新闻/图文,大数据量核心)

存储根:plugins/cms/data/splitdb/($root = __DIR__ . '/data/splitdb'),三层结构:

splitdb/
├── meta/main_index.sqlite    ← cms_index 索引表(列表/分页/统计唯一入口)
├── meta/global_id.sqlite     ← IDGenerator 全局取号(IDGenerator::initTable)
├── bucket/active/{YYYYQn}/{桶}.sqlite   ← cms_item 桶表(真相源,季度分区 + 哈希分桶)
└── extern/{YYYY}/{Qn}/{桶}/{id}.txt     ← 正文外置(ExternStorage 原子写)

3.3.1 cms_index — 内容索引表(meta/main_index.sqlite,自建)

CREATE TABLE IF NOT EXISTS cms_index (
    id           INTEGER PRIMARY KEY,           -- 来自 global_id 取号
    type         TEXT NOT NULL,                 -- news / gallery
    category_id  INTEGER NOT NULL DEFAULT 0,
    title        TEXT NOT NULL,
    summary      TEXT NOT NULL DEFAULT '',
    cover        TEXT NOT NULL DEFAULT '',
    author       TEXT NOT NULL DEFAULT '',
    source       TEXT NOT NULL DEFAULT '',
    views        INTEGER NOT NULL DEFAULT 0,    -- 浏览统计(简易计数)
    status       INTEGER NOT NULL DEFAULT 1,    -- 1=发布 0=草稿
    seo_title    TEXT NOT NULL DEFAULT '',
    seo_keywords TEXT NOT NULL DEFAULT '',
    seo_description TEXT NOT NULL DEFAULT '',
    create_time  INTEGER NOT NULL,              -- 秒级时间戳(SplitDB 约定)
    update_time  INTEGER NOT NULL,
    deleted_at   INTEGER,                       -- 软删标记(null=正常)
    bucket_path  TEXT NOT NULL                  -- 桶相对路径:bucket/active/{YYYYQn}/{桶}.sqlite
);
CREATE INDEX IF NOT EXISTS idx_cms_index_list ON cms_index (type, status, deleted_at, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_cms_index_cat  ON cms_index (category_id, status);

3.3.2 cms_item — 内容桶表(每桶一份,真相源)

CREATE TABLE IF NOT EXISTS cms_item (
    id           INTEGER PRIMARY KEY,
    type         TEXT NOT NULL,
    category_id  INTEGER NOT NULL DEFAULT 0,
    title        TEXT NOT NULL,
    summary      TEXT NOT NULL DEFAULT '',
    content      TEXT NOT NULL DEFAULT '',      -- 正文(新闻)/ 图文详情说明
    cover        TEXT NOT NULL DEFAULT '',
    images       TEXT NOT NULL DEFAULT '[]',    -- 图集 JSON 数组(gallery 多图,json_decode(...,true))
    author       TEXT NOT NULL DEFAULT '',
    source       TEXT NOT NULL DEFAULT '',
    views        INTEGER NOT NULL DEFAULT 0,
    status       INTEGER NOT NULL DEFAULT 1,
    seo_title    TEXT NOT NULL DEFAULT '',
    seo_keywords TEXT NOT NULL DEFAULT '',
    seo_description TEXT NOT NULL DEFAULT '',
    create_time  INTEGER NOT NULL,
    update_time  INTEGER NOT NULL,
    deleted_at   INTEGER,
    extern_path  TEXT NOT NULL DEFAULT ''       -- 正文外置相对路径:extern/{YYYY}/{Qn}/{桶}/{id}.txt
);
CREATE INDEX IF NOT EXISTS idx_cms_item_cat ON cms_item (category_id);

桶表由 Schema::bucketFromPath($bucket_path, $root) 打开的连接上 CREATE TABLE IF NOT EXISTS cms_item 自建(不复用核心 ensureBucketTables 的 topic/reply 表——那是论坛结构,CMS 字段不同)。

3.3.3 分片读写流程(对齐核心 Thread 模型,全部显式传 $root)

步骤操作调用
写① 取号全局 IDIDGenerator::nextId('cms_item', Schema::globalIdDb($root))
写② 路由季度 + 哈希分桶ShardRouter::quarter($ts) + ShardRouter::bucketForWrite($id, $createdAt)
写③ 正文正文落 externExternStorage::write($root, $id, $bucket, $content) → 返回相对 extern_path
写④ 桶行写真相源Schema::bucketDb($quarter, $bucket, $root) 连接上 INSERT cms_item
写⑤ 索引UPSERT 索引行Schema::mainIndexDb($root) 连接上 UPSERT cms_index(含 bucket_path)
读① 列表索引分页cms_index WHERE type/status/deleted_at IS NULL ORDER BY create_time DESC LIMIT/OFFSET
读② 详情索引→桶→正文索引行取 bucket_path → Schema::bucketFromPath($path, $root) → 桶行 + ExternStorage::read($root, $extern_path, $id)
删软删索引 + 桶行置 deleted_at(列表查询过滤;不物理删 extern,防路径穿越)
浏览原子计数UPDATE cms_index SET views = views + 1 WHERE id = :id(views 以索引行为准,列表/详情都读索引行)

要点:

  • 列表/分页/栏目统计只走 cms_index(天然有序 LIMIT 免排序),详情才开单桶——与核心 SplitDB 同构;
  • images 存 JSON,禁用 unserialize;
  • 写流程跨库无原子性(global_id + 桶 + 索引 + extern 分开),对齐核心取舍:先写桶(真相源)再 UPSERT 索引(最终一致),失败时日志可诊断(规范红线 21);
  • 全部 prepare() + 参数绑定;ID (int) 强转;bucket_path 用 bucketFromPath 解析(正则校验,防注入);

3.4 cms_pages — 单页(独立表、无分类、不参与列表分页)

CREATE TABLE IF NOT EXISTS cms_pages (
    id          INTEGER PRIMARY KEY,
    slug        TEXT NOT NULL UNIQUE,        -- 如 about / contact
    title       TEXT NOT NULL,
    content     TEXT NOT NULL DEFAULT '',
    status      INTEGER NOT NULL DEFAULT 1,
    seo_title   TEXT NOT NULL DEFAULT '',
    seo_keywords TEXT NOT NULL DEFAULT '',
    seo_description TEXT NOT NULL DEFAULT '',
    created_at  TEXT NOT NULL,
    updated_at  TEXT NOT NULL
);


4. Plugin.php 主类设计

命名空间 Plugin\Cms,结构如下(业务逻辑实现时填充):

<?php
namespace Plugin\Cms;

class Plugin
{
    /** 小表单库连接(cms_settings / cms_categories / cms_pages) */
    public static function db(): \PDO
    {
        return \app\Helpers\Plugin::db('cms');
    }

    /** 分片根目录(内容表 SplitDB,全部显式传 $root) */
    public static function splitdbRoot(): string
    {
        return __DIR__ . '/data/splitdb';
    }

    /** 小表单库 DDL(3 张表 + 索引,见 §3.1/3.2/3.4,幂等) */
    public static function ddl(): string { /* 3 段 CREATE TABLE/INDEX 拼接 */ }

    /** 激活:建小表 + 分片初始化 + 预置设置默认值 */
    public static function activate(): bool
    {
        \app\Helpers\Plugin::ensureSchema('cms', self::ddl());
        self::initSplitdb();                       // §3.3 分片初始化(幂等)
        // INSERT OR IGNORE 全部设置默认值(§3.1 表)
        return true;
    }

    public static function deactivate(): bool { return true; } // 临时禁用,不删表

    /** 卸载:删小表单库表 + 递归清 splitdb 目录(先清独立库,缓存由系统重建) */
    public static function uninstall(): void
    {
        $db = self::db();
        foreach (['cms_pages', 'cms_categories', 'cms_settings'] as $t) {
            $db->exec("DROP TABLE IF EXISTS {$t}");
        }
        self::rmdirRecursive(self::splitdbRoot()); // basename 防线内递归删除
    }

    // ---------- 分片基础设施(§3.3) ----------
    public static function initSplitdb(): void
    {
        $root = self::splitdbRoot();
        \app\SplitDB\Schema::ensureProtectionFiles($root);      // .htaccess/web.config 防 Web
        // cms_index 索引表(meta/main_index.sqlite)
        $mi = \app\SplitDB\Schema::mainIndexDb($root);
        $mi->exec(self::cmsIndexDdl());
        // global_id 取号表
        \app\SplitDB\IDGenerator::initTable(\app\SplitDB\Schema::globalIdDb($root));
    }

    /** 桶连接(打开或创建 cms_item 桶表,幂等) */
    private static function bucketDb(string $quarter, int $bucket): \PDO
    {
        $bdb = \app\SplitDB\Schema::bucketDb($quarter, $bucket, self::splitdbRoot());
        $bdb->exec(self::cmsItemDdl());            // 不复用核心 topic/reply 表
        return $bdb;
    }

    // ---------- 设置 ----------
    public static function getSetting(string $key, string $default = ''): string
    public static function getAllSettings(): array
    public static function saveSettings(array $kv): void      // upsert(事务内)

    // ---------- 栏目 ----------
    public static function getCategories(): array              // 全部,控制器组装两级树
    public static function getCategory(int $id): ?array
    public static function getTopCategories(): array
    public static function getChildren(int $parentId): array
    public static function countChildren(int $parentId): int
    public static function countContentsByCategory(int $catId): int  // 走 cms_index COUNT(索引行)
    public static function createCategory(array $d): array     // 返回 [ok, msg/id]
    public static function updateCategory(int $id, array $d): array
    public static function deleteCategory(int $id): array      // 有子栏目/内容则拒绝

    // ---------- 内容(SplitDB 分片读写,对齐核心 Thread 模型) ----------
    public static function listContents(string $type, array $filters, int $page, int $pageSize): array
        // 只走 cms_index:WHERE type/status/deleted_at IS NULL [+ category_id/关键词]
        // ORDER BY create_time DESC LIMIT/OFFSET → [rows, total](rows 含摘要/封面,详情才开桶)
    public static function getContent(int $id): ?array
        // 索引行取 bucket_path → Schema::bucketFromPath($path, $root) → 桶行 + ExternStorage::read
    public static function createContent(array $d): array
        // ① IDGenerator::nextId('cms_item', globalIdDb($root))
        // ② ShardRouter::quarter() + bucketForWrite($id,$createdAt)
        // ③ ExternStorage::write($root,$id,$bucket,$content) → extern_path
        // ④ bucketDb($quarter,$bucket) INSERT cms_item
        // ⑤ mainIndexDb($root) UPSERT cms_index(含 bucket_path)
        // 返回 [ok, msg/id];跨库无原子性:桶先写、索引后写,失败 error_log 真实异常
    public static function updateContent(int $id, array $d): array
        // 重写 extern(ExternStorage::write 原子替换)+ 更新桶行 + UPSERT 索引行
    public static function deleteContent(int $id): array
        // 软删:索引 + 桶行置 deleted_at(不物理删 extern,防路径穿越)
    public static function incrementViews(int $id): void
        // UPDATE cms_index SET views = views + 1 WHERE id = :id(原子,开关控制;views 以索引行为准)
    public static function getRelated(int $catId, int $excludeId, int $limit): array
        // cms_index 同栏目最近 N 条(LIMIT)

    // ---------- 单页(小表单库) ----------
    public static function getPages(): array
    public static function getPage(int $id): ?array
    public static function getPageBySlug(string $slug): ?array
    public static function createPage(array $d): array
    public static function updatePage(int $id, array $d): array
    public static function deletePage(int $id): array

    // ---------- 主题 ----------
    public static function getTheme(): string                  // 'news' | 'corp'(默认 news)
    public static function themeViewDir(): string              // 'plugins/cms/theme_news' | 'plugins/cms/theme_corp'
    public static function themeCssUrl(): string               // BASE_PATH + 对应主题 css + ?v=filemtime

    // ---------- SEO 拼接(供 FrontController 调用) ----------
    public static function buildSeo(array $settings, array $page): array
        // 规则:标题 = [seo_title_prefix] + 栏目/文章标题 + ' - ' + site_name
        //      keywords/description = 内容级 > 栏目级 > 全站默认

    // ---------- 私有工具 ----------
    private static function cmsIndexDdl(): string              // §3.3.1 建表 + 索引
    private static function cmsItemDdl(): string               // §3.3.2 建表 + 索引
    private static function rmdirRecursive(string $dir): void  // 递归删目录(basename 防线内)
}

约定:

  • 数据方法做静态属性运行时缓存(同请求避免重复查库)。
  • 所有 SQL prepare() + 参数绑定;ID 一律 (int);写操作 beginTransaction()/commit()/rollBack(),catch 内先 inTransaction() 判断再回滚,异常 error_log 后返回业务错误数组,不让异常逃逸到控制器(防 AJAX 收到 HTML 错误页)。

5. 后台架构(AdminController + 5 大模块)

5.1 路由注册(hook/admin_route_register.php)

<?php
// hook/admin_route_register.php
if (!isset($router)) return;

$router->get('/admin/cms', ['\Plugin\Cms\AdminController', 'index']);          // 默认跳转系统设置(或 tab 首页)
$router->get('/admin/cms/settings', ['\Plugin\Cms\AdminController', 'settings']);
$router->post('/admin/cms/settings', ['\Plugin\Cms\AdminController', 'settingsSave']);

$router->get('/admin/cms/categories', ['\Plugin\Cms\AdminController', 'categories']);
$router->post('/admin/cms/categories/add', ['\Plugin\Cms\AdminController', 'categoryAdd']);
$router->post('/admin/cms/categories/edit', ['\Plugin\Cms\AdminController', 'categoryEdit']);
$router->post('/admin/cms/categories/delete', ['\Plugin\Cms\AdminController', 'categoryDelete']);
$router->post('/admin/cms/categories/toggle', ['\Plugin\Cms\AdminController', 'categoryToggle']); // 启用/停用(htmx)

$router->get('/admin/cms/news', ['\Plugin\Cms\AdminController', 'news']);
$router->get('/admin/cms/news/add', ['\Plugin\Cms\AdminController', 'newsForm']);
$router->get('/admin/cms/news/edit/{id}', ['\Plugin\Cms\AdminController', 'newsForm']);
$router->post('/admin/cms/news/save', ['\Plugin\Cms\AdminController', 'contentSave']);      // type=news
$router->post('/admin/cms/news/delete', ['\Plugin\Cms\AdminController', 'contentDelete']);

$router->get('/admin/cms/gallery', ['\Plugin\Cms\AdminController', 'gallery']);
$router->get('/admin/cms/gallery/add', ['\Plugin\Cms\AdminController', 'galleryForm']);
$router->get('/admin/cms/gallery/edit/{id}', ['\Plugin\Cms\AdminController', 'galleryForm']);
// contentSave / contentDelete 共用(type=gallery),无需重复注册

$router->get('/admin/cms/pages', ['\Plugin\Cms\AdminController', 'pages']);
$router->get('/admin/cms/pages/add', ['\Plugin\Cms\AdminController', 'pageForm']);
$router->get('/admin/cms/pages/edit/{id}', ['\Plugin\Cms\AdminController', 'pageForm']);
$router->post('/admin/cms/pages/save', ['\Plugin\Cms\AdminController', 'pageSave']);
$router->post('/admin/cms/pages/delete', ['\Plugin\Cms\AdminController', 'pageDelete']);

$router->post('/admin/cms/upload', ['\Plugin\Cms\AdminController', 'upload']); // 图片上传(htmx/AJAX)

5.2 模块一:系统设置页(核心新增)

页面:views/admin/settings.php,GET 渲染表单(回显 Plugin::getAllSettings()),POST 保存。

表单分组与字段(对应需求"可配置项清单"):

分组字段(name)控件校验
前台主题themeselect:news=资讯新闻主题 / corp=企业官网主题in_array
站点信息site_nameinput text必填,≤60 字
site_subtitleinput text≤120 字
site_footerinput text≤200 字
功能开关news_enabledcheckbox0/1
gallery_enabledcheckbox0/1
list_show_datecheckbox0/1
list_show_thumbcheckbox0/1
views_enabledcheckbox0/1
全局 SEOseo_title_prefixinput text≤60 字
seo_keywordsinput text≤200 字
seo_descriptiontextarea≤300 字
分页page_sizeinput number5~100

交互:整页表单 POST(csrf 隐藏域 + Csrf::verifyOrDie()),保存成功 flash 提示并回显;也可在设置页内做"保存"局部刷新(htmx hx-post + 局部替换),二选一,首版用整页 POST 最稳。

5.3 模块二:栏目管理

页面:views/admin/categories.php —— 列表展示两级栏目(顶级缩进 0、二级缩进 1,parent_id 分组渲染),每行操作:添加子栏目、编辑、启用/停用、删除。

表单:views/admin/categories_form.php(Alpine 弹窗,放 .admin-content 内部,避免弹窗样式失效;或整页表单二选一——参考 qa 弹窗踩坑记录,弹窗 DOM 必须在作用域内或插件 assets 复刻控件样式)。

字段:name(必填)、parent_id(select:无/顶级栏目,仅列顶级)、type(news/gallery)、slug(唯一,可空则用 id 生成 URL)、sort(int)、enabled(checkbox)、seo_title/keywords/description(可选)。

校验(控制器):

  • parent_id 必须是 0 或存在的顶级栏目 id;若 parent_id > 0,该顶级栏目不能有父级(防第三级)。
  • slug 唯一((int) 强转 + prepare 绑定);空 slug 允许(URL 用 id)。
  • 删除:countChildren 或 countContentsByCategory > 0 → 拒绝并提示"请先删除子栏目/该栏目下内容"。

5.4 模块三:新闻管理 / 模块四:图文管理

列表页:views/admin/news.php / gallery.php —— 对齐 qa 后台表格(admin-table):

  • 列:ID、标题(缩略图可选)、栏目、作者、状态(发布/草稿 badge)、浏览量、发布时间、操作(编辑/删除)。
  • 筛选:关键词(标题 LIKE)、栏目 select、状态 select、分页($this->pagination() 或表格自带分页条)。
  • 顶部按钮:添加新闻/添加图文 → 跳转 add 表单页。

表单页:views/admin/content_form.php(新闻/图文共用,$type 区分):

  • 字段:title(必填)、category_id(select:两级栏目均可选,默认取顶级)、summary(textarea,列表/SEO 摘要)、content(textarea,纯文本+换行渲染,首版不做富文本)、cover(单图上传,预览)、images[](图文多图,上传列表 + 排序 + 删除)、author、source、status(发布/草稿 radio)、SEO 三字段。
  • 图片上传:POST /admin/cms/upload(见 §5.6),htmx 局部返回 <img>/缩略图块,data-* 属性承载 URL,外部 assets/script.js 更新隐藏 input(零内联)。
  • 校验:title 必填 ≤150 字;category_id 必须存在于 cms_categories;cover/images[] 必须是本站上传路径白名单——精确正则 ^/assets/uploads/[a-f0-9]{32}\.(?:jpg|jpeg|png|gif|webp)$(锁定 Upload::file() 的产出格式:UPLOAD_URL + bin2hex(random_bytes(16)) + 白名单后缀,天然拒绝外链 URL/手误/路径穿越)。校验点在 Plugin::createContent()/updateContent() 存库前最后一道(不只依赖控制器/前端)。

列表 URL 语义:新闻管理仅列 type='news',图文仅列 type='gallery'(listContents 按 type 过滤)。

5.5 模块五:单页管理

页面:views/admin/pages.php + page_form.php。

  • 列表列:ID、slug、标题、状态、更新时间、操作。
  • 表单字段:slug(必填,^[a-z0-9-]+$ 正则 + 唯一校验)、title(必填)、content(textarea)、status、SEO 三字段。
  • 无分类、不参与内容列表分页(独立表独立路由)。

5.6 图片上传(共用)

public function upload()
{
    Csrf::verifyOrDie($_POST['csrf'] ?? '');
    if (empty($_FILES['file'])) return $this->json(['ok' => false, 'msg' => 'no file']);
    $res = \app\Helpers\Upload::file($_FILES['file'], ['jpg', 'jpeg', 'png', 'gif', 'webp']);
    if (!$res['success']) return $this->json(['ok' => false, 'msg' => $res['error'] ?? 'upload failed']);
    // $res 含保存路径/URL(以 Upload::file 实际返回为准)
    return $this->json(['ok' => true, 'url' => $res['url'] ?? $res['path'] ?? '']);
}

  • 复用核心 Upload::file()(已内置 MIME 白名单 + random_bytes 强随机名 + 大小上限),不自己写 move_uploaded_file。
  • 成功返回 JSON {ok:true, url},前端 htmx data-* 接收(零内联)。
  • 删除内容时不物理删除图片文件(防路径穿越风险,首版保守:仅删 DB 记录;文件残留由上传目录定期清理,不做自动化)。
  • 存库规则:Upload::file() 成功返回 ['success'=>true, 'url'=>UPLOAD_URL.$filename](UPLOAD_URL='/assets/uploads/',config.php:23,不含 BASE_PATH)——DB 存原始 url(/assets/uploads/{hex}.{ext}),渲染时才拼 BASE_PATH,禁止二次拼接;存库前经精确正则校验(见 §5.4),此为后端最后一道关卡。

5.7 后台 UI 对齐约定(qa 后台)

  • 视图用 section('title') / section('css') / section('content');样式经 section('css') 引入 assets/style.css(?v=filemtime)。
  • 结构:.admin-container > main.admin-content > .admin-header(标题 + 操作按钮)> .admin-section(表格/表单),对齐 qa 的 admin-table / btn btn-sm btn-danger / alert alert-error|success。
  • 顶部 tab 导航(views/admin/_tabs.php):系统设置 / 栏目管理 / 新闻管理 / 图文管理 / 单页管理,当前页高亮(传 $activeTab)。
  • 所有写操作表单带 csrf 隐藏域;htmx 局部交互(启用/停用 toggle)带 hx-vals='{"csrf":...}' 或表单内 csrf,服务端 Csrf::verifyOrDie()。
  • 零内联:弹窗确认走 data-confirm + assets/script.js(参考 qa 的 qa-delete-form 模式);无内联 <script> / onclick / style=""。

6. 前台架构(FrontController + 双主题系统)

6.1 路由注册(hook/route_register.php)

<?php
// hook/route_register.php
if (!isset($router)) return;

$router->get('/cms', ['\Plugin\Cms\FrontController', 'index']);               // CMS 首页(按主题)
$router->get('/cms/category/{id}', ['\Plugin\Cms\FrontController', 'category']); // 栏目列表页
$router->get('/cms/news/{id}', ['\Plugin\Cms\FrontController', 'article']);    // 新闻详情
$router->get('/cms/gallery/{id}', ['\Plugin\Cms\FrontController', 'gallery']); // 图文详情
$router->get('/cms/page/{slug}', ['\Plugin\Cms\FrontController', 'page']);     // 单页

  • 路由前缀 /cms 与 qa 的 /qa 同模式;BASE_PATH 由核心路由处理,控制器内所有链接用 $this->url()。
  • 不注册 layout_head_end / nav_* 等全局钩子——CMS 前台是独立完整页面(viewRaw 渲染自带 <html> 骨架),不进主站布局,避免与主站导航/页脚/缓存互相污染。

6.2 渲染方式:viewRaw + 主题布局(关键决策)

CMS 前台不用 $this->view()(主布局),改用 $this->viewRaw() 渲染插件自带的完整 HTML 页面:

FrontController
 ├─ 读设置(theme / site_name / SEO / 开关)
 ├─ 按主题选视图目录:views/theme_news 或 views/theme_corp
 ├─ 渲染「主题 layout.php」(<html><head>SEO/主题CSS/JS</head><body>顶部栏+内容区+页脚</body>)
 └─ 内容区 include 对应页面(index/category/article/gallery/page)

理由:

  1. 需求要求「前台页脚版权文案」独立于主站、网站名称可不同、企业主题要「顶部全局导航 + 区块化首页」——套主布局无法实现独立站点感;
  2. 避免主布局的 layout_head_end 钩子群(公告/插件资源)在 CMS 页白跑,性能干净;
  3. viewRaw 已确认存在(app/Core/Controller.php:76),只做安全响应头,不注入主布局数据——CMS 数据全部由 FrontController 自行组装。

独立页面的公共数据(FrontController 组装):

  • site_name / site_subtitle / site_footer(来自 cms_settings,非主站);
  • seo(Plugin::buildSeo() 拼接后的 title/keywords/description);
  • theme_news(是否资讯主题,供 layout 选 css/导航样式);
  • categories(两级栏目树,供侧栏/顶部导航);
  • topPages(启用的单页,供顶部导航/页脚链接);
  • currentNav(当前栏目/单页 slug,高亮导航);
  • back_home($this->url('/') 主站首页 URL,layout 页脚"← 返回主站"轻量链接用——CMS 独立页面无主站导航,必须留一个回主站的门,禁止硬编码 /)。

6.3 公共前台页面(两主题各自独立模板)

页面路由视图(theme_news / theme_corp)内容
首页/cmsindex.php资讯主题:侧栏栏目导航 + 主列表流;企业主题:hero + 图文区块 + 新闻区块 + 单页区块
栏目列表/cms/category/{id}category.php二级栏目内容分页列表;按 news_enabled/gallery_enabled 开关与栏目 type 过滤
新闻详情/cms/news/{id}article.php标题/作者/来源/时间/浏览量/正文;浏览量计数(views_enabled 开启时)
图文详情/cms/gallery/{id}gallery.php封面 + 图集(images JSON)+ 说明正文
单页/cms/page/{slug}page.php独立静态页(关于/联系等),status=1 才可访问,slug 不存在 → 404 视图

状态处理:

  • 内容 status=0(草稿)或模块开关关闭 → 前台 404($this->view('errors/404') 或 viewRaw 版 404 页)。
  • 栏目 enabled=0 → 列表页 404;导航中不显示。

6.4 双主题切换逻辑

$theme = \Plugin\Cms\Plugin::getTheme();          // 'news' | 'corp'
$viewDir = 'plugins/cms/theme_' . $theme;         // 视图前缀
$css = \Plugin\Cms\Plugin::themeCssUrl();         // 主题 css 的完整 URL(含 BASE_PATH + ?v=filemtime)

  • 设置切换即时生效(无页面缓存;CMS 前台不经 PageCache,见 §6.6),后台保存 theme 键后下一次前台请求即用新主题。
  • 业务逻辑(查询、SEO、浏览量、分页)与主题无关,仅视图/样式分支。

6.5 公共组件(两主题复用)

组件位置说明
面包屑_partials/breadcrumb.php首页 > 栏目 > 当前($crumbs 数组)
分页$this->pagination($page, $totalPages, $url)核心方法已确认可用(TemplateCompiler:374),主题内套一层样式类
SEO 渲染layout.php 内<title> + meta keywords/description(Plugin::buildSeo())
列表项_partials/list_item.php缩略图(list_show_thumb)+ 标题 + 摘要 + 时间(list_show_date)
侧栏/顶部导航layout 内栏目树 + 单页链接,enabled=1 才显示

6.6 缓存与浏览量(已验证无冲突)

  • PageCache 前置静态缓存只拦截 3 个 key:home_、forum_、forum/category/N(index.php:62-70 已确认)。/cms/ 路由不会命中 PageCache,无需 invalidate(),浏览量可实时计数,后台改内容前台立即生效——零缓存处理成本**。
  • 浏览量:GET /cms/news/{id} / /cms/gallery/{id} 时若 views_enabled=1,执行 UPDATE cms_index SET views = views + 1 WHERE id = :id(原子 UPDATE,无并发问题;views 以索引行为准)。
  • 若未来 CMS 页接入主站导航(可选增强),再按规范处理 PageCache::invalidate() 与 sw.js 缓存(htmx 片段需 Cache-Control: no-store + HX-Request 豁免,见规范 11.5-30)。首版独立页面无此问题。

7. CSS 样式设计规范(双主题 + 变量化)

7.1 分层

层文件职责
基础层assets/style.css公共组件:卡片、按钮、列表项、表单、面包屑、分页、badge、空状态。**全部用 --mn-* 变量**
主题层assets/theme-news.css资讯主题专属:侧栏布局、列表流密度、阅读体验
主题层assets/theme-corp.css企业主题专属:顶部导航、hero、区块化首页、图文产品卡片
脚本assets/script.jshtmx 初始化、上传预览交互、确认弹窗、图片排序(零内联)

7.2 变量化铁律(暗夜星辰/浅色双主题适配)

  • 引用已确认存在的核心变量(assets/css/modern.css:2265 起 :root):--mn-primary、--mn-primary-light、--mn-bg、--mn-bg-card、--mn-bg-body、--mn-text、--mn-text-secondary、--mn-text-muted、--mn-border、--mn-border-light、--mn-success/error/warning、--mn-bg-tag、--mn-text-inverse。
  • 禁用:--mn-card-bg、--mn-muted(不存在,拼错=静默失效);硬编码 #fff/#333/主色/语义色;旧变量 --primary-color/--border-color/--text-dark。
  • 写法:var(--mn-bg-card, #fff)、var(--mn-text-muted, #888)——fallback 即浅色主题目标值。
  • 首版 CMS 前台是独立页面(不自动跟随主站暗夜主题),但样式仍全部变量化,保证未来可一键接入暗夜;后台视图套主布局,自动适配。

7.3 双主题隔离

  • theme-news.css 与 theme-corp.css 互不引用,由 Plugin::themeCssUrl() 按当前主题只输出其一(layout.php 中 <link>)。
  • 类名前缀防冲突:cms-(公共)、cmsn-(news 主题)、cmsc-(corp 主题)。
  • 公共组件(style.css)两主题共用,主题层只做布局差异。

7.4 零内联细则(对照规范 7.3 全量执行)

  • 无内联 <script> 块 / on* 事件属性 / style="" 静态样式(动态色值用预定义 class 或 --xxx-bg 内联 CSS 变量注入,属合规)。
  • 弹窗确认:data-confirm 属性 + assets/script.js 监听(参考 qa 的 qa-delete-form)。
  • i18n 文案注入 JS:data-* 属性承载 JSON(htmlspecialchars(json_encode(...), ENT_QUOTES)),script.js 启动时解析。
  • 发布前 grep 校验:grep -nE '<style|<script>|on(click|change|submit|load)=' plugins/cms/views plugins/cms/hook 应零命中(<script src> 除外)。

8. 语言包设计(lang/zh.php · en.php · zh_tw.php)

键名前缀 plugin.cms.,至少 zh + en(zh_tw 同步)。分区:

区键示例
导航/标题title、nav_*plugin.cms.title = CMS资讯
后台 tabtab_settings/categories/news/gallery/pages—
后台列表th_id/title/category/author/status/time/actions—
表单field_*(theme/site_name/site_subtitle/site_footer/news_enabled/gallery_enabled/list_show_date/list_show_thumb/views_enabled/seo_title_prefix/seo_keywords/seo_description/page_size/parent_id/slug/sort/cover/images/status/summary/content/source…)—
校验/错误err_*(后台表单校验文案:err_title_required、err_category_invalid、err_slug_format、err_slug_exists、err_parent_level、err_delete_has_children、err_delete_has_content…)—
成功提示msg_*(msg_saved、msg_added、msg_deleted…)—
前台交互*_act 后缀(empty_act、read_more_act、back_home_act、not_found_act…)前台文案一律 _act 后缀
状态status_published/status_draft、on/off—

校验:发布前脚本比对「视图/控制器用到的 plugin.cms.* 键」vs 三个语言包,零缺失(防止下拉框显示键名原文)。

共用文案策略(防前后台双套键):前后台共用文案(保存/删除/成功提示等)统一用**无后缀 msg_* 键(如 msg_saved),前后台复用同一键,不建 _act 双套;仅面向前台用户的交互提示**用 _act、后台表单校验用 err_(遵循规范 §8 约定)。校验脚本同时核对 msg_* 共用键前后台引用一致,杜绝"后台'已保存'、前台'Save Success'"不一致。


9. 安全红线自查清单(对照规范逐条落实)

红线落实点
1. 无 basename 不落盘首版不物理删除上传文件;若未来删文件,basename() 校验后才 unlink()
2. 无预处理不执行全部 prepare() + 绑定;IN (...) 用 array_map('intval') + 占位符生成
3. 输出必转义所有输出 $this->e() / htmlspecialchars(..., ENT_QUOTES, 'UTF-8')
4. 禁反序列化/动态执行images 用 json_decode($json, true);无 eval/assert
5. 禁滥用绝对路径路径基于 __DIR__ / BASE_PATH;URL 用 $this->url() / $bp
6. 渲染钩子禁中断本插件仅 3 个非渲染钩子;无 die/exit/header 于渲染场景
7. 上传白名单+随机名复用 Upload::file()(内置白名单 + random_bytes + MIME 校验)
8. 原子扣减浏览量 views = views + 1 原子 UPDATE;无先查后改
9. PDO 不覆盖核心属性直接用 Plugin::db(),不改 PDO 属性
10. AJAX 必带 CSRF全部 POST(表单 + htmx)带 csrf,服务端 Csrf::verifyOrDie()
11. IDOR所有按 ID 操作 (int) 强转;后台权限由主程序管理员鉴权兜底
12. 开放重定向redirect() 只允许 / 开头相对路径
13. 事务beginTransaction()/commit()/rollBack(),catch 内 inTransaction() 判断,异常 error_log 后返回业务错误


10. 分阶段开发步骤(实施顺序)

阶段内容产出验证
P1 骨架目录 + plugin.json + Plugin.php(ddl/activate/uninstall + 设置方法 + initSplitdb)+ 3 个 hook 空壳 + 空 lang可激活插件php -l;激活后小表 + splitdb 目录建好;Plugin::refresh()
P2 分片层Plugin.php 分片读写(cms_index/cms_item/extern/global_id + createContent/getContent/listContents/deleteContent/incrementViews)+ CLI 压测脚本 cli/bench.php分片读写闭环CLI 冒烟:写入→索引→读详情→软删;单条链路正确
P3 后台AdminController + 5 个后台视图 + _tabs.php + 上传接口后台全模块可操作走通增删改查/开关/分页/上传;CSRF 校验
P4 主题1theme_news layout + index/category/article/gallery/page + theme-news.css资讯主题前台前台全路由走通;浏览量计数;分页;SEO
P5 主题2theme_corp 全套 + theme-corp.css企业主题前台双主题切换即时生效
P6 压测cli/bench.php 大数据量灌库(10 万/100 万条)→ 列表分页/详情/浏览量/栏目统计耗时基准压测报告见 §11.5 压测项
P7 收尾语言包全量 + 零内联检查 + 双主题走查 + 卸载自检 + 说明.md发布就绪见 §11

每阶段完成即验证,不跨阶段堆积。


11. 验证与发布清单

  1. php -l 全部新文件(用 D:\phpstudy_pro\Extensions\php\php8.2.9nts\php.exe);
  2. 激活插件 → 3 张小表建表成功、splitdb 分片目录(meta/main_index + global_id)初始化成功、设置默认值写入;
  3. 注册 hooks 后刷新 plugins/plugins_cache.json(删文件或 Plugin::refresh()),确认钩子生效;
  4. 后台:5 模块增删改查、栏目两级校验、图片上传、CSRF、分页(新闻/图文数据经分片读写);
  5. 前台:5 路由 + 双主题切换 + 开关(news_enabled/gallery_enabled/list_show_date/list_show_thumb/views_enabled)+ SEO 标题拼接 + 分页 + 面包屑;
  6. 语言键全量比对(视图/控制器用键 vs zh/en/zh_tw);
  7. 零内联 grep 零命中;
  8. 双主题走查(浅色 + 暗夜星辰后台,前台至少浅色完整走查);
  9. 大数据量压测(cli/bench.php,见 §11.5);
  10. 卸载 → 3 小表删除、splitdb 目录递归清除、Plugin::getPlugin('cms') 返回 null、无残留;
  11. 清理测试数据、删除临时文件、同步 update/、更新 CHANGELOG。

11.5 大数据量压测项(SplitDB 方案验收,P6 必做)

cli/bench.php(CLI,仿 ai_assistant/cli/worker.php 的入口守卫 + Autoloader):

项做法通过标准
灌库--count 100000 / --count 1000000 批量 createContent(随机栏目/type/时间,正文 2~10KB)10 万条完成;速率 > 500 条/s(extern + 桶 + 索引批量写)
列表分页随机栏目/type 翻页(page 1/100/1000/10000)每页 < 50ms(走 cms_index 索引,LIMIT 免排序)
详情随机 id getContent(索引→桶→extern)< 10ms
浏览量随机 id incrementViews 并发 100 次views 精确 +N,无锁冲突报错
栏目统计countContentsByCategory 全栏目< 20ms(索引 COUNT)
分片分布打印各季度/桶行数(SELECT bucket_path, COUNT(*))均匀分布(±20%)
软删随机删 1000 条 → 列表不可见、索引/桶 deleted_at 置位列表过滤正确

压测脚本只清理自己生成的测试数据(标题带 [BENCH] 前缀),绝不触碰真实数据(参照 points_mall CLI 验证脚本铁律)。收尾清理必须三处全清:[BENCH] 前缀查 cms_index 拿 id 列表 → 逐 id 经 Schema::bucketFromPath($bucket_path, $root) 物理 DELETE cms_item(basename 防线内)→ 按 ShardRouter::externRel($id, $quarter, $bucket) 物理删 extern 文件(id/季度/桶已知,路径可控)→ 最后删索引行。避免桶表/extern 残留测试垃圾、污染后续压测分布。


12. 明确不开发(首版冻结,禁止膨胀)

对照需求第七节:无限级栏目 ❌ / 自定义字段、多内容模型 ❌ / 招聘、分类信息 ❌ / 付费、置顶、会员权限 ❌ / 评论、点赞、流量详细统计 ❌ / 静态页面生成 ❌ / 多配色皮肤、移动端单独适配 ❌(自适应即可)。


13. 首版不做但可留接口(后续增强候选)

  • 主站导航挂载 CMS 入口(nav_plugin_links + PageCache/SW 处理);
  • 内容富文本编辑(首版 textarea + 换行渲染);
  • 图片文件随内容删除物理清理(需 basename 防线);
  • CMS 前台暗夜主题跟随(变量已就绪,补一个主题检测即可)。

14. 存储选型定稿:启用 SplitDB 分片(大数据量测试需求)

决策记录(2026-09-01):用户因需在 CMS 内进行大数据量测试(10 万~百万级内容),明确要求启用 SplitDB 分片存储,而非单 SQLite。本插件采用混合架构(§3):小表(设置/栏目/单页)留插件私有单库,内容表(新闻/图文)走插件目录内 SplitDB 分片。本节记录机制核实结论与风险备忘。

14.1 SplitDB 机制核实结论(已读 app/SplitDB/ 全部核心代码)

  • 架构:meta/main_index.sqlite(索引表)+ bucket/active/{季度}/{桶}.sqlite(哈希分桶真相源,默认 32 桶)+ extern/(正文外置 .txt 文件)+ meta/global_id.sqlite(取号)。
  • 写:IDGenerator::nextId() 全局取号 → ShardRouter::quarter() 季度分区 + bucketForWrite() 哈希路由 → 写桶 + ExternStorage::write() 原子写正文 → 写 main_index 索引行(UPSERT)。
  • 读:列表/分页走索引表(天然有序 LIMIT)→ 详情按 bucket_path 读单桶 + extern 正文。
  • 关键事实:所有 Schema/ShardRouter 方法都接受 $dataPath 参数(mainIndexDb($dataPath)、bucketDb($quarter,$bucket,$dataPath)、bucketFromPath($bucketPath,$dataPath)、ShardRouter::bucketAbs/externAbs/ensureBucketDir/ensureExternDir($dataPath)、Schema::ensureProtectionFiles($root))——整套分片体系可完整放进插件目录 plugins/cms/data/splitdb/,与核心库(全局 data/)零冲突。这是本方案可行的根基。

14.2 采用混合架构而非全量分片(边界)

数据存储理由
cms_settings / cms_categories / cms_pages插件私有单库 cms.sqlite量小(几十~几百行)、低频读写,分片无收益且徒增复杂度
cms_contents(新闻/图文)SplitDB 分片(§3.3)大数据量测试主战场:百万级行、正文大文件,分片 + extern 外置收益显著

14.3 分片读写设计(已并入 §3.3 / §4,此处为速览)

写:IDGenerator::nextId('cms_item', globalIdDb($root))          // ① 取号
    → ShardRouter::quarter() + bucketForWrite($id, $createdAt)  // ② 路由
    → ExternStorage::write($root, $id, $bucket, $content)       // ③ 正文外置
    → bucketDb($quarter, $bucket, $root) INSERT cms_item        // ④ 桶真相源
    → mainIndexDb($root) UPSERT cms_index                       // ⑤ 索引行(含 bucket_path)

读列表:cms_index WHERE type/status/deleted_at IS NULL ORDER BY create_time DESC LIMIT/OFFSET
读详情:索引行 bucket_path → Schema::bucketFromPath($path, $root) → cms_item + extern 正文
删:软删(索引 + 桶置 deleted_at);浏览量:UPDATE cms_index SET views = views + 1(原子)

不复用核心 topic/reply 桶表与 Thread 模型(论坛字段结构与 CMS 不同、且硬编码全局 dataPath)——自建 cms_index/cms_item(§3.3.1/3.3.2),但复用 Schema/ShardRouter/IDGenerator/ExternStorage/DBFactory 全部基础设施(传 $root 即可)。

14.4 风险与注意点(实施时逐条对照)

  1. 跨库无原子性:写流程跨 global_id + 桶 + 索引 + extern 四个文件,无跨库事务——对齐核心取舍:先写桶(真相源)再 UPSERT 索引(最终一致);失败必须 error_log 真实异常(规范红线 21),返回业务错误,不许吞成笼统文案;
  2. DBFactory 连接池上限 8 句柄(LRU):列表页只开 main_index,详情页开 main_index + 单桶,均在预算内;压测批量写时注意句柄复用,避免频繁开新桶导致 LRU 抖动;
  3. bucket_path 校验:解析必须走 Schema::bucketFromPath(正则校验),禁止字符串拼接路径(路径穿越红线);
  4. 卸载清理:uninstall() 先 DROP 3 张小表,再 rmdirRecursive 递归删 data/splitdb/(basename 防线内),缓存由系统重建;卸载自检确认 Plugin::getPlugin('cms') 返回 null;
  5. extern 物理清理:软删/删除内容不物理删 extern 文件(防路径穿越);压测数据由 cli/bench.php 按 [BENCH] 前缀自清(points_mall CLI 铁律);
  6. 压测灌库速率:期望 > 500 条/s;若慢,检查 WAL(PRAGMA journal_mode=WAL 已由 DBFactory 统一设置)与批量事务(CLI 脚本可分批 beginTransaction 加速——extern 写为文件 IO,桶/索引批量写可事务化)。

14.5 触发"回退单库"或"再升级"的条件

  • 若压测证明百万级下分片收益不明显、且维护成本过高 → 可回退单库(§14 旧方案已删,回退需重写 §3.3 为单表 + 数据搬迁脚本);
  • 若行数超百万、需 64/128 桶 → 调 ShardRouter::bucketSize() 相关配置(核心 SPLITDB_BUCKET_SIZE 常量),历史桶零迁移(bucket_path 读取制);
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 2 | Replies 0

All Replies (0)

No replies yet

Please Log in