仰望星辰工作室

feat(plugin): 插件系统支持配置声明、发帖守卫与帖子标识

F fqh 提交于 2026-09-12 23:33 · a37daa9 ·父提交 99028bc
feat(plugin): 插件系统支持配置声明、发帖守卫与帖子标识

插件内核
- plugin.json 新增 requires / config 字段,可声明后台自动渲染的配置表单
- 新增 guard(compose_guard)钩子:命中昵称时强制校验后台管理员账号密码
- 新增 badge(post_badge)钩子:给指定昵称的帖子写入自定义标识
- 新增 {config:key} 占位符,钩子字符串可引用后台配置值
- 新增 GuardNicknames(),把插件守卫昵称下发给发帖页用于弹窗提示
- 插件控制台新增插件设置表单与 admin_plugins_top HTML 注入点

宿主适配
- posts 表新增 badges 列(JSON 标识列表);旧库自动 ALTER,兼容 SQLite/MySQL
- 发帖(表单 + API)统一走 guardCompose:内置管理员昵称与插件守卫合并校验
- 帖子标识统一渲染:内置「管理员」+ 插件标识,前台卡片/详情页/个人主页均可显示
- API 返回 badges 字段;管理员专享昵称与插件守卫共用同一套失败限流

文档与示例
- docs/PLUGIN.md 全面重写:配置项类型、9 个钩子、guard/badge 详解、发布与调试
- 新增示例插件 plugins/official-guard(官方身份守卫),并随附打包 zip
15 个文件变更 +696 -125 fangqihang1717@163.com
•README.md +6 -1
•docs/PLUGIN.md +215 -64
•internal/database/database.go +3 -2
•internal/handlers/admin.go +49 -2
•internal/handlers/api.go +9 -17
•internal/handlers/front.go +65 -25
•internal/models/models.go +55 -1
•internal/plugin/plugin.go +203 -6
•main.go +1 -0
•plugins/official-guard.zip 二进制
•plugins/official-guard/plugin.json +44 -0
•web/static/app.css +6 -2
•web/static/app.js +5 -2
•web/templates/admin.html +33 -1
•web/templates/front.html +2 -2
变更内容
diff --git a/README.md b/README.md
index 630c255..4f0ed76 100644
--- a/README.md
+++ b/README.md
@@ -395,9 +395,14 @@ docker run -d --name clearlove --restart=always \
 
 | 钩子类型 | 钩子名 | 用途 |
 |---|---|---|
-| `html` | `header_html` / `footer_html` | 向页面头部 / 底部注入 HTML |
+| `html` | `header_html` / `footer_html` / `admin_plugins_top` | 向页面头部 / 底部 / 后台插件页注入 HTML |
 | `filter` | `content_filter` | 正则改写帖子与评论内容 |
 | `http` | `post_created` / `comment_created` / `report_created` | 事件 Webhook(POST JSON,带 `X-ClearLove-Event` 头) |
+| `guard` | `compose_guard` | 发帖守卫:命中昵称时强制验证后台管理员账号,通过后才允许发布 |
+| `badge` | `post_badge` | 给指定昵称的帖子打上自定义标识 |
+
+插件还可在 `config` 中**声明配置项**,后台「插件管理 → 插件设置」会自动渲染表单,
+钩子字段用 `{config:key}` 引用配置值(示例见 [plugins/official-guard](plugins/official-guard))。
 
 发布流程:在云端 <https://clearlove.kazx.top/dev/register> 注册开发者(邮箱验证)→ 开发者后台上传 zip → 管理员审核上架 → 所有站点即可一键安装。详见 [docs/PLUGIN.md](docs/PLUGIN.md)。
 
diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md
index 40f39b2..212158b 100644
--- a/docs/PLUGIN.md
+++ b/docs/PLUGIN.md
@@ -1,105 +1,256 @@
-# 插件开发文档
+# ClearLove 插件开发文档
 
-ClearLove 表白墙采用 **钩子(Hook)模式** 插件系统。插件以 zip 包上传,
-解压后安装于 `data/plugins/<插件名>/`,可在后台 **插件管理** 中启用 / 禁用 / 卸载。
+插件用于在不修改表白墙源码的前提下扩展功能。插件是**声明式**的:一个 zip 包 + 一份 `plugin.json`,
+无需编译、无需服务端代码,安装后由表白墙内核解释执行,因此可以安全地在插件市场分发。
+
+---
 
 ## 1. 插件包结构
 
 ```
-my-plugin.zip
-└── plugin.json      # 插件清单(必须)
+your-plugin.zip
+├── plugin.json          # 必需:插件清单(放在根目录或任意子目录均可)
+└── (可选)其他静态资源
 ```
 
-(可附带自定义资源文件,服务端会一并解压到插件目录。)
+打包要求:
+
+- 必须是 zip 格式,`plugin.json` 需为 **UTF-8** 编码
+- 插件名(`name`)会被用作目录名,建议使用中文或英文短名,避免 `/`、`\` 等字符
+- 单包不超过 50MB
+
+安装方式:
+
+| 方式 | 操作 |
+|---|---|
+| 后台上传 | 后台「插件管理 → 安装插件」选择 zip → 上传并安装 |
+| 插件市场 | 后台「插件商店」中一键安装(自动下载并校验 SHA256) |
+| 手动部署 | 解压到 `data/plugins/<插件名>/` 后刷新后台插件列表 |
 
-## 2. plugin.json 清单
+安装后需在「插件管理」中点击**启用**才会生效。
+
+---
+
+## 2. plugin.json 完整字段
 
 ```json
 {
-  "name": "anti-ad",
+  "name": "插件名(必需,唯一)",
   "version": "1.0.0",
-  "author": "夏日之瓜",
-  "description": "自动过滤广告关键词并推送到审核机器人",
-  "hooks": [
-    {
-      "hook": "content_filter",
-      "type": "filter",
-      "match": "(加微信|代购|刷单)",
-      "replace": "***"
-    },
-    {
-      "hook": "post_created",
-      "type": "http",
-      "url": "https://your-bot.example.com/webhook"
-    },
-    {
-      "hook": "footer_html",
-      "type": "html",
-      "html": "<div style=\"text-align:center;font-size:12px\">由 anti-ad 插件提供支持</div>"
-    }
-  ]
+  "author": "作者",
+  "description": "一句话说明",
+  "requires": "2.0.0",
+  "config": [ ... ],
+  "hooks": [ ... ]
 }
 ```
 
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `name` | ✅ | 插件名,同时作为安装目录名 |
+| `version` | | 版本号,展示用 |
+| `author` | | 作者名 |
+| `description` | | 简介,展示在插件列表与插件市场 |
+| `requires` | | 建议的最低表白墙版本(仅作展示提示) |
+| `config` | | 配置项声明,见第 3 节 |
+| `hooks` | ✅ | 钩子列表,见第 4 节 |
+
+---
+
+## 3. 配置项(config)
+
+插件可以声明若干配置项,表白墙会**自动在后台「插件管理 → 插件设置」渲染表单**,
+管理员填写后立即生效,无需重新安装插件。
+
+```json
+"config": [
+  { "key": "nicknames", "label": "专享昵称", "type": "textarea",
+    "default": "官方,公告", "help": "多个昵称用逗号或换行分隔" },
+  { "key": "badge",     "label": "帖子标识", "type": "text", "default": "官方" },
+  { "key": "enable",    "label": "启用提示", "type": "switch", "default": "1" },
+  { "key": "position",  "label": "显示位置", "type": "select",
+    "default": "bottom", "options": ["top", "bottom"] }
+]
+```
+
 | 字段 | 说明 |
 |---|---|
-| name | 插件唯一名(安装目录名,必填) |
-| version / author / description | 展示信息 |
-| hooks[].hook | 钩子名(见下表) |
-| hooks[].type | `html` / `filter` / `http` 三种类型 |
+| `key` | 字段名,供 `{config:key}` 引用 |
+| `label` | 后台表单里显示的名称 |
+| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` |
+| `default` | 未配置时的默认值 |
+| `help` | 表单下方的说明文字 |
+| `options` | 仅 `select` 使用,候选项数组 |
 
-## 3. 钩子一览
+### 在钩子里引用配置
 
-### 3.1 html 类型 —— 页面注入
+任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值:
 
-| 钩子名 | 注入位置 |
-|---|---|
-| `header_html` | `<body>` 顶部(全站,含后台) |
-| `footer_html` | 页脚区域 |
+```json
+{ "hook": "compose_guard", "type": "guard",
+  "nicknames": "{config:nicknames}",
+  "label": "{config:badge}" }
+```
+
+配置值保存在数据库 `settings` 表(键名 `plugin.<插件名>.<字段名>`),随站点一起备份。
+
+---
+
+## 4. 钩子一览
+
+`hooks` 是一个数组,每个元素至少包含 `hook` 与 `type`:
+
+| type | hook | 作用 | 关键字段 |
+|---|---|---|---|
+| `html` | `header_html` | 前台页面 `<head>` 后注入 HTML | `html` |
+| `html` | `footer_html` | 前台页面底部注入 HTML | `html` |
+| `html` | `admin_plugins_top` | 后台「插件管理」页顶部注入 HTML | `html` |
+| `filter` | `content_filter` | 用正则改写帖子 / 评论内容 | `match`、`replace` |
+| `http` | `post_created` | 发帖成功后回调 Webhook | `url` |
+| `http` | `comment_created` | 评论后回调 Webhook | `url` |
+| `http` | `report_created` | 举报后回调 Webhook | `url` |
+| `guard` | `compose_guard` | **发帖前置守卫**:命中昵称时强制验证后台账号 | `nicknames`、`require`、`label`、`message` |
+| `badge` | `post_badge` | 给指定昵称的帖子打上标识 | `nicknames`、`label` |
+
+> Webhook 以 `POST` + JSON 发送,请求头带 `X-ClearLove-Event`,超时 10 秒,失败只记录日志、不影响发帖。
+
+### html 钩子示例
+
+```json
+{ "hook": "footer_html", "type": "html",
+  "html": "<div class='daily-quote'>今天也要加油鸭</div>" }
+```
+
+注入的 HTML 会被原样输出到页面,因此**可以包含 `<style>` 与 `<script>`** —— 这也是插件实现
+自定义交互(弹窗、动效、统计)的主要方式。请勿注入恶意代码。
+
+### filter 钩子示例
+
+```json
+{ "hook": "content_filter", "type": "filter", "match": "广告|加群|代刷", "replace": "***" }
+```
+
+`match` 是 Go 正则表达式,对所有发帖与评论内容生效(插件启用后 30 秒内自动热加载)。
 
-`html` 字段为注入的 HTML 片段(**不会**被模板转义,请勿注入脚本恶意代码)。
+### http 钩子示例
 
-### 3.2 filter 类型 —— 内容过滤
+```json
+{ "hook": "post_created", "type": "http", "url": "https://example.com/hook" }
+```
+
+回调体包含事件名与业务字段,例如发帖回调:
+
+```json
+{ "event": "post_created", "post_id": 12, "nickname": "匿名", "content": "...", "time": "2026-09-12T15:00:00Z" }
+```
 
-| 钩子名 | 触发时机 |
+---
+
+## 5. guard:发帖守卫(需要验证后台账号)
+
+`compose_guard` 用于**防止他人冒充官方身份**:当发帖昵称命中名单时,
+表白墙会在提交前弹出「管理员身份验证」弹窗,要求输入后台管理员账号与密码,
+**服务端校验通过后才会发布**(不通过直接拒绝,无法绕过),并可给帖子打上标识。
+
+| 字段 | 说明 |
 |---|---|
-| `content_filter` | 发帖(前台与 API)时对内容执行正则替换 |
+| `nicknames` | 触发的昵称名单,逗号 / 分号 / 换行分隔,支持 `{config:key}` |
+| `require` | `admin` = 必须验证后台管理员账号密码;留空表示仅打标识、不校验 |
+| `label` | 校验通过后写入帖子的标识文字(前台显示为昵称后的彩色徽章) |
+| `message` | 校验失败时返回给用户的提示语 |
 
-- `match`:Go 正则表达式;`replace`:替换文本(支持 `$1` 分组引用)。
-- 多条规则按插件加载顺序依次执行,规则缓存 30 秒。
+```json
+{
+  "hook": "compose_guard",
+  "type": "guard",
+  "nicknames": "官方,公告板",
+  "require": "admin",
+  "label": "官方",
+  "message": "该昵称为官方专享,请验证管理员账号后发布"
+}
+```
 
-### 3.3 http 类型 —— 事件 Webhook
+行为说明:
 
-| 钩子名 | 触发时机 | 载荷字段 |
-|---|---|---|
-| `post_created` | 帖子发布成功 | post_id, nickname, content |
-| `comment_created` | 评论发布成功 | post_id, content |
-| `report_created` | 收到举报 | post_id, reason |
+- 前台发帖页会在昵称命中时**自动弹出验证弹窗**(昵称名单由插件配置实时提供)
+- 表单发帖(`/compose`)与 API 发帖(`POST /api/v1/posts`)都会强制校验
+- API 发帖需在 JSON 中额外提供 `admin_user` 与 `admin_pass`
+- 同一 IP 连续失败 5 次会被锁定 10 分钟,防止暴力破解后台密码
+- 多个插件同时命中时,标识会合并、校验要求取并集
+- 通过验证的帖子会同时带上内置的「**管理员**」标识(表示发布者已验证为管理员),
+  插件自定义的 `label` 会追加在后面,例如 `["管理员", "官方认证"]`
+
+### badge:帖子标识(无需验证)
+
+只想给某些昵称的帖子加个标记时使用,不涉及验证:
+
+```json
+{ "hook": "post_badge", "type": "badge", "nicknames": "小编,编辑", "label": "编辑" }
+```
+
+标识在发帖时写入帖子记录,**不随插件卸载而消失**(已发布的历史帖子仍保留标识)。
+
+---
 
-服务端以 `POST` 方式异步请求 `url`,`Content-Type: application/json`,
-请求头带 `X-ClearLove-Event: <钩子名>`,载荷中额外包含 `event` 与 `time` 字段。
-超时 10 秒,失败仅记录日志、不影响主流程。
+## 6. 完整示例:官方身份守卫插件
 
-## 4. 示例:投稿同步到企业微信机器人
+`plugin.json`:
 
 ```json
 {
-  "name": "wecom-notify",
+  "name": "官方身份守卫",
   "version": "1.0.0",
+  "author": "yourname",
+  "description": "使用指定昵称发帖时必须验证后台管理员账号,帖子显示自定义标识,防止冒充官方身份",
+  "requires": "2.0.0",
+  "config": [
+    { "key": "nicknames", "label": "专享昵称", "type": "textarea", "default": "官方,公告",
+      "help": "使用这些昵称发帖时需验证后台账号,多个用逗号或换行分隔" },
+    { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方",
+      "help": "验证通过的帖子在昵称后显示的徽章文字" },
+    { "key": "message", "label": "失败提示", "type": "text",
+      "default": "该昵称为官方专享,请验证管理员账号后发布" }
+  ],
   "hooks": [
-    { "hook": "post_created", "type": "http", "url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" }
+    { "hook": "compose_guard", "type": "guard",
+      "nicknames": "{config:nicknames}",
+      "require": "admin",
+      "label": "{config:badge}",
+      "message": "{config:message}" },
+    { "hook": "footer_html", "type": "html",
+      "html": "<style>.post-badge{box-shadow:0 1px 6px rgba(255,123,169,.45)}</style>" }
   ]
 }
 ```
 
-## 5. 安装与管理
+打包与安装:
 
-1. 后台 → 插件管理 → 选择 zip → 上传并安装;
-2. 在列表中「启用」,即刻生效(无需重启,规则缓存最长 30 秒);
-3. 「禁用」暂停全部钩子,「卸载」删除插件目录并从启用列表移除。
+```bash
+zip -r official-guard.zip plugin.json
+```
+
+上传到后台「插件管理 → 安装插件」→ 启用 → 在「插件设置」里填写专享昵称即可。
+
+> 表白墙内置了「管理员专享昵称」(网站设置),覆盖最常见的官方身份保护场景;
+> 这个插件演示的是如何用插件实现同类能力,并支持自定义标识文字与多插件组合。
+
+---
 
-## 6. 限制与安全
+## 7. 发布到插件市场
 
-- 单个 zip 不超过 50MB;路径穿越(`../`)文件会被忽略。
-- Webhook 为异步执行,不会阻塞用户请求;请勿在钩子中依赖同步返回。
-- 插件为「配置式」钩子,不加载第三方二进制代码,天然隔离运行风险。
+1. 在插件社区注册开发者账号(邮箱验证):<https://clearlove.kazx.top/dev/register>
+2. 开发者后台「发布插件」上传 zip,填写分类、说明与标签
+3. 管理员审核上架后,所有站点都能在后台「插件商店」中一键安装
+4. 之后可在开发者后台查看下载量与安装量,并上传新版本
+
+---
+
+## 8. 调试建议
+
+| 场景 | 建议 |
+|---|---|
+| 改了 plugin.json 不生效 | 插件清单每次请求都会重新读取磁盘,但 `content_filter` 规则有 30 秒缓存 |
+| 配置改了没生效 | 配置存在 `settings` 表并带内存缓存,保存后立即刷新,无需重启 |
+| 钩子没触发 | 确认插件已在「插件管理」中**启用**(安装 ≠ 启用) |
+| 前台注入的 JS 报错 | 打开浏览器控制台查看;注入内容会在渲染时直接输出,注意转义引号 |
+| 想看当前插件与配置 | 后台「插件管理」页可查看全部插件、状态与配置表单 |
diff --git a/internal/database/database.go b/internal/database/database.go
index 2f941ed..93ba24d 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -70,7 +70,7 @@ CREATE TABLE IF NOT EXISTS posts (
 	id {PK}, user_id {I} DEFAULT 0, nickname VARCHAR(64) DEFAULT '匿名', content {LT},
 	topic_id {I} DEFAULT 0, ip VARCHAR(64) DEFAULT '', fingerprint VARCHAR(128) DEFAULT '',
 	status {I} DEFAULT 1, like_count {I} DEFAULT 0, comment_count {I} DEFAULT 0,
-	is_admin {I} DEFAULT 0, created_at VARCHAR(40)
+	is_admin {I} DEFAULT 0, badges VARCHAR(255) DEFAULT '', created_at VARCHAR(40)
 );
 
 CREATE TABLE IF NOT EXISTS medias (
@@ -126,12 +126,13 @@ func Migrate() error {
 			}
 		}
 	}
-	// 兼容旧库:posts 增加 is_admin 列(列已存在时报错,忽略即可)
+	// 兼容旧库:posts 增加 is_admin / badges 列(列已存在时报错,忽略即可)
 	alter := "ALTER TABLE posts ADD COLUMN is_admin INTEGER DEFAULT 0"
 	if Driver == "mysql" {
 		alter = "ALTER TABLE posts ADD COLUMN is_admin INT DEFAULT 0"
 	}
 	_, _ = DB.Exec(alter)
+	_, _ = DB.Exec("ALTER TABLE posts ADD COLUMN badges VARCHAR(255) DEFAULT ''")
 
 	// 常用索引(忽略已存在错误)
 	indexes := []string{
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index d45b2d9..10e1858 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -7,6 +7,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"html/template"
 	"io"
 	"io/fs"
 	"net/http"
@@ -719,12 +720,58 @@ func AdminNoticesSave(w http.ResponseWriter, r *http.Request) {
 
 // ---------- 插件管理 ----------
 
-// AdminPlugins 插件列表
+// AdminPlugins 插件列表(含各插件声明的配置表单)
 func AdminPlugins(w http.ResponseWriter, r *http.Request) {
 	if a := requireAdmin(w, r, models.PermPlugin); a == nil {
 		return
 	}
-	Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{"plugins": plugin.List()})
+	list := plugin.List()
+	configs := map[string]map[string]string{}
+	for i := range list {
+		if len(list[i].Config) > 0 {
+			p := list[i].Plugin
+			configs[p.Name] = plugin.GetConfig(&p)
+		}
+	}
+	Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{
+		"plugins": list,
+		"configs": configs,
+		// 插件可通过 {"hook":"admin_plugins_top","type":"html"} 往本页顶部注入内容
+		"adminTop": template.HTML(plugin.CallHTML("admin_plugins_top")),
+	})
+}
+
+// AdminPluginConfig 保存插件配置(仅接受该插件声明过的字段)
+func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
+	if a := requireAdmin(w, r, models.PermPlugin); a == nil {
+		return
+	}
+	name := filepath.Base(r.FormValue("plugin"))
+	p, ok := plugin.Load()[name]
+	if !ok {
+		http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
+		return
+	}
+	values := map[string]string{}
+	for _, f := range p.Config {
+		if f.Key == "" {
+			continue
+		}
+		if f.Type == "switch" {
+			values[f.Key] = "0"
+			if r.FormValue("cfg_"+f.Key) == "1" {
+				values[f.Key] = "1"
+			}
+			continue
+		}
+		values[f.Key] = r.FormValue("cfg_" + f.Key)
+	}
+	if err := plugin.SaveConfig(p, values); err != nil {
+		http.Error(w, "保存失败: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	util.Log("info", "插件 %s 配置已更新", name)
+	http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
 }
 
 // AdminPluginUpload 上传安装插件 zip
diff --git a/internal/handlers/api.go b/internal/handlers/api.go
index 5800415..b63f648 100644
--- a/internal/handlers/api.go
+++ b/internal/handlers/api.go
@@ -184,29 +184,21 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
 	if nickname == "" {
 		nickname = "匿名"
 	}
-	// 管理员专享昵称:校验后台账号密码
-	isAdmin := 0
-	if models.IsAdminNickname(nickname) {
-		ip := util.ClientIP(r)
-		if adminTryBlocked(ip) {
-			fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
-			return
-		}
-		if !verifyAdminCreds(strings.TrimSpace(body.AdminUser), body.AdminPass) {
-			adminTryFail(ip)
-			fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
-			return
-		}
-		adminTryReset(ip)
-		isAdmin = 1
+	// 发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard
+	isAdmin, badgeList, code, gErr := guardCompose(nickname,
+		strings.TrimSpace(body.AdminUser), body.AdminPass, util.ClientIP(r))
+	if gErr != "" {
+		fail(w, code, gErr)
+		return
 	}
 	topicID := int64(0)
 	if t := util.StripHTML(body.Topic); t != "" {
 		topicID = ensureTopic(t)
 	}
 	res, err := database.DB.Exec(
-		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
-		userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"), isAdmin, models.Now())
+		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,?,?,?)",
+		userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"),
+		isAdmin, models.BadgesJSON(badgeList), models.Now())
 	if err != nil {
 		fail(w, 500, "发布失败")
 		return
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index 2a6b504..ad37d74 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -72,6 +72,44 @@ func adminTryReset(ip string) {
 	delete(adminTries, ip)
 }
 
+// guardCompose 执行发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard 钩子。
+// 返回 (是否管理员帖, 帖子标识列表, 错误码, 错误提示)
+func guardCompose(nickname, adminUser, adminPass, ip string) (int, []string, int, string) {
+	labels := plugin.NicknameBadges(nickname)
+	needAdmin := false
+	msg := ""
+
+	if models.IsAdminNickname(nickname) {
+		needAdmin = true
+		labels = append(labels, "管理员")
+		msg = "「" + nickname + "」为管理员专享昵称,请填写正确的后台账号与密码"
+	}
+	if g := plugin.CheckComposeGuard(nickname); g != nil {
+		labels = append(labels, g.Labels...)
+		if g.Require {
+			needAdmin = true
+			if msg == "" {
+				msg = g.Message
+			}
+		}
+	}
+	if !needAdmin {
+		return 0, models.MergeBadges(labels), 0, ""
+	}
+	if msg == "" {
+		msg = "该昵称为管理员专享,请填写正确的后台账号与密码"
+	}
+	if adminTryBlocked(ip) {
+		return 0, nil, 429, "验证失败次数过多,请 10 分钟后再试"
+	}
+	if !verifyAdminCreds(adminUser, adminPass) {
+		adminTryFail(ip)
+		return 0, nil, 403, msg
+	}
+	adminTryReset(ip)
+	return 1, models.MergeBadges(labels), 0, ""
+}
+
 // CommentItem 评论预览项
 type CommentItem struct {
 	Nickname  string `json:"nickname"`
@@ -91,6 +129,8 @@ type Card struct {
 	Likes        int64         `json:"likes"`
 	CommentCount int64         `json:"comment_count"`
 	IsAdmin      int           `json:"is_admin"`
+	BadgesRaw    string        `json:"-"`
+	Badges       []string      `json:"badges"`
 	Comments     []CommentItem `json:"comments"`
 	CreatedAt    string        `json:"created_at"`
 }
@@ -112,7 +152,7 @@ func latestNotice() (int64, string, string) {
 func fetchCards(before int64, limit int, topic string) []Card {
 	q := `
 		SELECT p.id,p.user_id,IFNULL(p.nickname,'匿名'),IFNULL(p.content,''),IFNULL(t.name,''),
-		       p.like_count,p.comment_count,IFNULL(p.is_admin,0),p.created_at
+		       p.like_count,p.comment_count,IFNULL(p.is_admin,0),IFNULL(p.badges,''),p.created_at
 		FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
 		WHERE p.status=1 AND (? <= 0 OR p.id < ?)`
 	args := []any{before, before}
@@ -133,7 +173,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
 	for rows.Next() {
 		c := Card{Images: []string{}, Videos: []string{}, Comments: []CommentItem{}}
 		if err := rows.Scan(&c.ID, &c.UserID, &c.Nickname, &c.Content, &c.Topic,
-			&c.Likes, &c.CommentCount, &c.IsAdmin, &c.CreatedAt); err == nil {
+			&c.Likes, &c.CommentCount, &c.IsAdmin, &c.BadgesRaw, &c.CreatedAt); err == nil {
 			order = append(order, c.ID)
 			byID[c.ID] = &c
 		}
@@ -181,6 +221,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
 	out := make([]Card, 0, len(order))
 	for _, id := range order {
 		c := byID[id]
+		c.Badges = models.DisplayBadges(int64(c.IsAdmin), c.BadgesRaw)
 		// 评论按时间正序展示
 		for i, j := 0, len(c.Comments)-1; i < j; i, j = i+1, j-1 {
 			c.Comments[i], c.Comments[j] = c.Comments[j], c.Comments[i]
@@ -269,6 +310,7 @@ func singleCard(id int64) *Card {
 	c := &Card{
 		ID: p.ID, UserID: p.UserID, Nickname: p.Nickname, Content: p.Content,
 		Likes: p.LikeCount, CommentCount: p.CommentCount, IsAdmin: p.IsAdmin,
+		Badges:    models.DisplayBadges(int64(p.IsAdmin), p.BadgesRaw),
 		CreatedAt: p.CreatedAt,
 		Images:    []string{}, Videos: []string{}, Comments: []CommentItem{},
 	}
@@ -326,8 +368,10 @@ func ComposePage(w http.ResponseWriter, r *http.Request) {
 		"maxImages":    15, "maxVideos": 3,
 		// AI 发帖预审是否生效(开启且模型已接入)——前端据此展示审核动画
 		"aiPrecheck": models.GetSetting("ai_precheck") == "1" && ai.Enabled(),
-		// 管理员专享昵称(逗号连接),前端据此在提交时弹出后台账号验证弹窗
-		"adminNicks": strings.Join(models.AdminNicknames(), ","),
+		// 需要验证后台账号的昵称(内置管理员昵称 + 插件守卫),
+		// 前端据此在提交时弹出账号验证弹窗
+		"adminNicks": strings.Join(
+			append(models.AdminNicknames(), plugin.GuardNicknames()...), ","),
 	})
 }
 
@@ -370,21 +414,12 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
 		nickname = "匿名"
 	}
 
-	// 管理员专享昵称:必须校验后台账号密码,通过后才允许发布并标记为管理员帖
-	isAdmin := 0
-	if models.IsAdminNickname(nickname) {
-		ip := util.ClientIP(r)
-		if adminTryBlocked(ip) {
-			fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
-			return
-		}
-		if !verifyAdminCreds(strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass")) {
-			adminTryFail(ip)
-			fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
-			return
-		}
-		adminTryReset(ip)
-		isAdmin = 1
+	// 发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard
+	isAdmin, badgeList, code, gErr := guardCompose(nickname,
+		strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass"), util.ClientIP(r))
+	if gErr != "" {
+		fail(w, code, gErr)
+		return
 	}
 
 	// 先全量校验文件(数量/大小/格式),再落盘,避免部分文件写入后失败
@@ -463,8 +498,9 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
 
 	// 入库
 	res, err := database.DB.Exec(
-		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
-		userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin, models.Now())
+		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,?,?,?)",
+		userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin,
+		models.BadgesJSON(badgeList), models.Now())
 	if err != nil {
 		fail(w, 500, "发布失败,请稍后重试")
 		return
@@ -595,8 +631,8 @@ func PostEditSave(w http.ResponseWriter, r *http.Request) {
 func loadPost(id int64) *models.Post {
 	p := &models.Post{}
 	err := database.DB.QueryRow(
-		"SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE id=?", id,
-	).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
+		"SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(badges,''),IFNULL(created_at,'') FROM posts WHERE id=?", id,
+	).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.BadgesRaw, &p.CreatedAt)
 	if err != nil {
 		return nil
 	}
@@ -648,7 +684,7 @@ func Profile(w http.ResponseWriter, r *http.Request) {
 		where = "user_id=?"
 	}
 	rows, err := database.DB.Query(
-		"SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
+		"SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(badges,''),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
 	if err != nil {
 		http.Error(w, "查询失败", http.StatusInternalServerError)
 		return
@@ -661,12 +697,16 @@ func Profile(w http.ResponseWriter, r *http.Request) {
 		LikeCount    int64
 		CommentCount int64
 		IsAdmin      int
+		BadgesRaw    string
+		Badges       []string
 		CreatedAt    string
 	}
 	var posts []own
 	for rows.Next() {
 		var p own
-		_ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
+		_ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount,
+			&p.IsAdmin, &p.BadgesRaw, &p.CreatedAt)
+		p.Badges = models.DisplayBadges(int64(p.IsAdmin), p.BadgesRaw)
 		posts = append(posts, p)
 	}
 	Render(w, r, "layout.html", "pg_profile", map[string]any{
diff --git a/internal/models/models.go b/internal/models/models.go
index 26bb491..69b9a57 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -72,10 +72,64 @@ type Post struct {
 	Status       int // 1 可见 0 隐藏(AI/管理员处理)
 	LikeCount    int64
 	CommentCount int64
-	IsAdmin      int // 1 = 管理员专享昵称发布(已通过后台账号验证)
+	IsAdmin      int    // 1 = 管理员专享昵称发布(已通过后台账号验证)
+	BadgesRaw    string // 插件写入的标识列表(JSON 数组字符串)
 	CreatedAt    string
 }
 
+// ---------- 帖子标识(内置管理员标识 + 插件 badge 标识) ----------
+
+// BadgesJSON 标识列表转 JSON 字符串(空列表返回空串,便于兼容旧数据)
+func BadgesJSON(list []string) string {
+	if len(list) == 0 {
+		return ""
+	}
+	b, err := json.Marshal(list)
+	if err != nil {
+		return ""
+	}
+	return string(b)
+}
+
+// ParseBadges 解析 JSON 标识列表
+func ParseBadges(s string) []string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return nil
+	}
+	var out []string
+	if err := json.Unmarshal([]byte(s), &out); err != nil {
+		return nil
+	}
+	return out
+}
+
+// MergeBadges 合并多个标识列表并去重(保持首次出现的顺序)
+func MergeBadges(lists ...[]string) []string {
+	var out []string
+	seen := map[string]bool{}
+	for _, list := range lists {
+		for _, v := range list {
+			v = strings.TrimSpace(v)
+			if v == "" || seen[v] {
+				continue
+			}
+			seen[v] = true
+			out = append(out, v)
+		}
+	}
+	return out
+}
+
+// DisplayBadges 计算帖子最终展示的标识(管理员标识 + 插件标识)
+func DisplayBadges(isAdmin int64, raw string) []string {
+	var builtin []string
+	if isAdmin == 1 {
+		builtin = append(builtin, "管理员")
+	}
+	return MergeBadges(builtin, ParseBadges(raw))
+}
+
 // AdminNicknames 后台配置的「管理员专享昵称」列表。
 // 支持中英文逗号、分号与换行分隔,用于防止他人冒充官方身份。
 func AdminNicknames() []string {
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index ffb9483..92c0280 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -27,23 +27,43 @@ import (
 	"clearlove/internal/util"
 )
 
+// ConfigField 插件配置项声明。后台「插件管理」会按此自动渲染设置表单,
+// 配置值保存在 settings 表中(键名前缀 plugin.<插件名>.),可在钩子中通过
+// {config:字段名} 占位符引用。
+type ConfigField struct {
+	Key     string   `json:"key"`     // 字段名(供 {config:key} 引用)
+	Label   string   `json:"label"`   // 显示名称
+	Type    string   `json:"type"`    // text | textarea | switch | select | number
+	Default string   `json:"default"` // 默认值
+	Help    string   `json:"help"`    // 说明文字
+	Options []string `json:"options"` // select 型的候选项
+}
+
 // HookDef 单个钩子定义
 type HookDef struct {
 	Hook    string `json:"hook"`    // 钩子名
-	Type    string `json:"type"`    // html | filter | http
+	Type    string `json:"type"`    // html | filter | http | guard | badge
 	HTML    string `json:"html"`    // html 型:注入片段
 	Match   string `json:"match"`   // filter 型:正则
 	Replace string `json:"replace"` // filter 型:替换文本
 	URL     string `json:"url"`     // http 型:Webhook 地址
+
+	// guard / badge 型专用
+	Nicknames string `json:"nicknames"` // 触发昵称,逗号分隔,支持 {config:key}
+	Require   string `json:"require"`   // admin = 必须验证后台账号密码
+	Label     string `json:"label"`     // 命中后写入帖子的标识文字
+	Message   string `json:"message"`   // 校验失败时的提示语
 }
 
 // Plugin 插件清单
 type Plugin struct {
-	Name        string    `json:"name"`
-	Version     string    `json:"version"`
-	Author      string    `json:"author"`
-	Description string    `json:"description"`
-	Hooks       []HookDef `json:"hooks"`
+	Name        string        `json:"name"`
+	Version     string        `json:"version"`
+	Author      string        `json:"author"`
+	Description string        `json:"description"`
+	Requires    string        `json:"requires"` // 建议的最低表白墙版本(仅提示)
+	Config      []ConfigField `json:"config"`   // 配置项声明(可选)
+	Hooks       []HookDef     `json:"hooks"`
 }
 
 // Meta 插件目录信息(管理页展示)
@@ -110,6 +130,183 @@ func List() []Meta {
 	return out
 }
 
+// ---------- 插件配置 ----------
+
+// configKey 插件配置在 settings 表中的键名
+func configKey(pluginName, key string) string {
+	return "plugin." + pluginName + "." + key
+}
+
+// GetConfig 读取插件配置(未设置的字段回落到声明的默认值)
+func GetConfig(p *Plugin) map[string]string {
+	out := make(map[string]string, len(p.Config))
+	for _, f := range p.Config {
+		if f.Key == "" {
+			continue
+		}
+		v := models.GetSetting(configKey(p.Name, f.Key))
+		if v == "" {
+			v = f.Default
+		}
+		out[f.Key] = v
+	}
+	return out
+}
+
+// ConfigOf 按插件名读取配置(插件不存在时返回空表)
+func ConfigOf(pluginName string) map[string]string {
+	if p, ok := Load()[pluginName]; ok {
+		return GetConfig(p)
+	}
+	return map[string]string{}
+}
+
+// SaveConfig 保存插件配置(只接受该插件声明过的字段)
+func SaveConfig(p *Plugin, values map[string]string) error {
+	for _, f := range p.Config {
+		if f.Key == "" {
+			continue
+		}
+		if v, ok := values[f.Key]; ok {
+			if err := models.SetSetting(configKey(p.Name, f.Key), util.StripHTML(v)); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// reConfigVar 配置占位符 {config:key}
+var reConfigVar = regexp.MustCompile(`\{config:[A-Za-z0-9_\-]+\}`)
+
+// expand 展开 {config:key} 占位符
+func expand(s string, cfg map[string]string) string {
+	if s == "" || !strings.Contains(s, "{config:") {
+		return s
+	}
+	return reConfigVar.ReplaceAllStringFunc(s, func(m string) string {
+		return cfg[m[len("{config:"):len(m)-1]]
+	})
+}
+
+// splitNames 拆分逗号/分号/换行分隔的名单
+func splitNames(s string) []string {
+	fields := strings.FieldsFunc(s, func(r rune) bool {
+		return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\r'
+	})
+	out := make([]string, 0, len(fields))
+	for _, f := range fields {
+		if v := strings.TrimSpace(f); v != "" {
+			out = append(out, v)
+		}
+	}
+	return out
+}
+
+// ---------- 发帖守卫(guard)与帖子标识(badge) ----------
+
+// GuardResult 发帖守卫判定结果
+type GuardResult struct {
+	Require bool     // true = 必须验证后台管理员账号密码
+	Labels  []string // 通过后写入帖子的标识文字
+	Message string   // 未通过时的提示语
+}
+
+// CheckComposeGuard 检查昵称是否命中任一启用插件的发帖守卫(compose_guard)。
+// 未命中返回 nil。多个插件同时命中时会合并标识与要求。
+func CheckComposeGuard(nickname string) *GuardResult {
+	name := strings.TrimSpace(nickname)
+	if name == "" {
+		return nil
+	}
+	enabled := enabledSet()
+	res := &GuardResult{}
+	hit := false
+	for pname, p := range Load() {
+		if !enabled[pname] {
+			continue
+		}
+		cfg := GetConfig(p)
+		for _, h := range p.Hooks {
+			if h.Type != "guard" || h.Hook != "compose_guard" {
+				continue
+			}
+			matched := false
+			for _, n := range splitNames(expand(h.Nicknames, cfg)) {
+				if strings.EqualFold(n, name) {
+					matched = true
+					break
+				}
+			}
+			if !matched {
+				continue
+			}
+			hit = true
+			if h.Require == "admin" {
+				res.Require = true
+			}
+			if h.Label != "" {
+				res.Labels = append(res.Labels, expand(h.Label, cfg))
+			}
+			if res.Message == "" && h.Message != "" {
+				res.Message = expand(h.Message, cfg)
+			}
+		}
+	}
+	if !hit {
+		return nil
+	}
+	return res
+}
+
+// GuardNicknames 返回所有「需要验证后台账号」的守卫昵称。
+// 发帖页据此在提交前弹出账号验证弹窗,避免用户提交后才看到报错。
+func GuardNicknames() []string {
+	enabled := enabledSet()
+	var out []string
+	for pname, p := range Load() {
+		if !enabled[pname] {
+			continue
+		}
+		cfg := GetConfig(p)
+		for _, h := range p.Hooks {
+			if h.Type != "guard" || h.Hook != "compose_guard" || h.Require != "admin" {
+				continue
+			}
+			out = append(out, splitNames(expand(h.Nicknames, cfg))...)
+		}
+	}
+	return out
+}
+
+// NicknameBadges 返回该昵称在发帖时应携带的标识(post_badge 钩子)
+func NicknameBadges(nickname string) []string {
+	name := strings.TrimSpace(nickname)
+	if name == "" {
+		return nil
+	}
+	enabled := enabledSet()
+	var out []string
+	for pname, p := range Load() {
+		if !enabled[pname] {
+			continue
+		}
+		cfg := GetConfig(p)
+		for _, h := range p.Hooks {
+			if h.Type != "badge" || h.Hook != "post_badge" || h.Label == "" {
+				continue
+			}
+			for _, n := range splitNames(expand(h.Nicknames, cfg)) {
+				if strings.EqualFold(n, name) {
+					out = append(out, expand(h.Label, cfg))
+					break
+				}
+			}
+		}
+	}
+	return out
+}
+
 // Install 从上传的 zip 安装插件(zip 内应包含 plugin.json)
 func Install(filename string, data []byte) error {
 	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
diff --git a/main.go b/main.go
index 70d03f0..0aac06f 100644
--- a/main.go
+++ b/main.go
@@ -154,6 +154,7 @@ func buildMux() *http.ServeMux {
 	mux.HandleFunc("POST /admin/plugins/upload", handlers.AdminPluginUpload)
 	mux.HandleFunc("POST /admin/plugins/toggle", handlers.AdminPluginToggle)
 	mux.HandleFunc("POST /admin/plugins/delete", handlers.AdminPluginDelete)
+	mux.HandleFunc("POST /admin/plugins/config", handlers.AdminPluginConfig)
 	// 插件商店(对接云端插件社区)
 	mux.HandleFunc("GET /admin/store", handlers.AdminStore)
 	mux.HandleFunc("POST /admin/store/install", handlers.AdminStoreInstall)
diff --git a/plugins/official-guard.zip b/plugins/official-guard.zip
new file mode 100644
index 0000000..5203c1d
Binary files /dev/null and b/plugins/official-guard.zip differ
diff --git a/plugins/official-guard/plugin.json b/plugins/official-guard/plugin.json
new file mode 100644
index 0000000..0b9eaff
--- /dev/null
+++ b/plugins/official-guard/plugin.json
@@ -0,0 +1,44 @@
+{
+  "name": "官方身份守卫",
+  "version": "1.0.0",
+  "author": "ClearLove",
+  "description": "使用指定昵称发帖时必须验证后台管理员账号,帖子显示自定义标识,防止冒充官方身份",
+  "requires": "2.0.0",
+  "config": [
+    {
+      "key": "nicknames",
+      "label": "专享昵称",
+      "type": "textarea",
+      "default": "官方,公告",
+      "help": "使用这些昵称发帖时需验证后台账号,多个用逗号或换行分隔"
+    },
+    {
+      "key": "badge",
+      "label": "帖子标识",
+      "type": "text",
+      "default": "官方",
+      "help": "验证通过的帖子在昵称后显示的徽章文字"
+    },
+    {
+      "key": "message",
+      "label": "失败提示",
+      "type": "text",
+      "default": "该昵称为官方专享,请验证管理员账号后发布"
+    }
+  ],
+  "hooks": [
+    {
+      "hook": "compose_guard",
+      "type": "guard",
+      "nicknames": "{config:nicknames}",
+      "require": "admin",
+      "label": "{config:badge}",
+      "message": "{config:message}"
+    },
+    {
+      "hook": "footer_html",
+      "type": "html",
+      "html": "<style>.post-badge{box-shadow:0 1px 6px rgba(255,123,169,.45)}</style>"
+    }
+  ]
+}
diff --git a/web/static/app.css b/web/static/app.css
index 1ff36f2..57544d1 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -256,15 +256,19 @@ textarea { resize: vertical; }
 .captcha-modal-box .form-err { margin: 10px 0 0; }
 .captcha-modal-box .btn-row { margin-top: 14px; }
 
-/* ---------------- 管理员专享昵称 ---------------- */
+/* ---------------- 昵称标识(管理员 / 插件 badge) ---------------- */
 .nick-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
-.admin-badge {
+.post-badge {
   display: inline-flex; align-items: center; flex: none;
   padding: 1px 8px; border-radius: 999px;
   font-size: 11px; font-weight: 600; letter-spacing: .5px;
   background: linear-gradient(135deg, var(--clv-accent), var(--clv-accent-2));
   color: #fff;
 }
+
+/* ---------------- 插件设置表单(后台) ---------------- */
+.plugin-config h3 { margin-bottom: 6px; }
+.plugin-config .plugin-help { margin: -6px 0 8px; }
 .admin-verify-box { width: min(360px, 100%); }
 .admin-verify-box h3 { margin-bottom: 6px; }
 .admin-verify-box > .muted { font-size: 13px; margin: 0 0 6px; }
diff --git a/web/static/app.js b/web/static/app.js
index 7583f71..1e61b9c 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -132,8 +132,11 @@
     }
     var nickRow = el('div', 'nick-row');
     nickRow.appendChild(nick);
-    // 管理员专享昵称发布的帖子:显示「管理员」标识
-    if (p.is_admin) nickRow.appendChild(el('span', 'admin-badge', '管理员'));
+    // 帖子标识(内置「管理员」+ 插件 badge 钩子写入)
+    var badges = p.badges || [];
+    for (var bi = 0; bi < badges.length; bi++) {
+      nickRow.appendChild(el('span', 'post-badge', badges[bi]));
+    }
     meta.appendChild(nickRow);
     var t = el('time', '', timeText(p.created_at));
     meta.appendChild(t);
diff --git a/web/templates/admin.html b/web/templates/admin.html
index f35b2cf..384f3a9 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -461,8 +461,40 @@
     <input type="file" name="file" accept=".zip" required>
     <button class="btn btn-primary" type="submit">上传并安装</button>
   </form>
-  <p class="muted">插件为 zip 包,需包含 plugin.json(钩子式:html 注入 / filter 内容过滤 / http 事件 Webhook)。开发文档见项目 docs/PLUGIN.md。</p>
+  <p class="muted">插件为 zip 包,需包含 plugin.json,支持 html 注入 / filter 内容过滤 / http 事件 Webhook / guard 发帖守卫 / badge 帖子标识。开发文档见项目 docs/PLUGIN.md。</p>
 </div>
+
+{{if .adminTop}}
+<div class="card">{{.adminTop}}</div>
+{{end}}
+
+{{range .plugins}}
+{{if .Config}}
+<form class="card plugin-config" method="post" action="/admin/plugins/config">
+  <input type="hidden" name="_csrf" value="{{$.csrf}}">
+  <input type="hidden" name="plugin" value="{{.Name}}">
+  <h3>{{.Name}} <small class="muted">v{{.Version}} 插件设置</small></h3>
+  {{$cfg := index $.configs .Name}}
+  {{range .Config}}
+  <label>{{.Label}}
+    {{if eq .Type "textarea"}}
+    <textarea name="cfg_{{.Key}}" rows="3" placeholder="{{.Default}}">{{index $cfg .Key}}</textarea>
+    {{else if eq .Type "switch"}}
+    <span class="check-row"><input type="checkbox" name="cfg_{{.Key}}" value="1" {{if eq (index $cfg .Key) "1"}}checked{{end}}> 开启</span>
+    {{else if eq .Type "select"}}
+    {{$cur := index $cfg .Key}}
+    <select name="cfg_{{.Key}}">{{range .Options}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}}</select>
+    {{else}}
+    <input type="text" name="cfg_{{.Key}}" value="{{index $cfg .Key}}" placeholder="{{.Default}}">
+    {{end}}
+  </label>
+  {{if .Help}}<p class="muted small plugin-help">{{.Help}}</p>{{end}}
+  {{end}}
+  <div class="btn-row"><button class="btn btn-primary" type="submit">保存设置</button></div>
+</form>
+{{end}}
+{{end}}
+
 <div class="card table-card">
   <h3>已安装插件</h3>
   <div class="table-scroll">
diff --git a/web/templates/front.html b/web/templates/front.html
index 0f53bad..8f81901 100644
--- a/web/templates/front.html
+++ b/web/templates/front.html
@@ -20,7 +20,7 @@
     <div class="meta">
       <div class="nick-row">
         {{if .UserID}}<a class="nickname" href="/u/{{.UserID}}">{{.Nickname}}</a>{{else}}<span class="nickname">{{.Nickname}}</span>{{end}}
-        {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+        {{range .Badges}}<span class="post-badge" title="该帖带有管理员或插件标识">{{.}}</span>{{end}}
       </div>
       <time>{{datefmt .CreatedAt}}</time>
     </div>
@@ -259,7 +259,7 @@
       <div class="meta">
         <div class="nick-row">
           <span class="nickname">{{.Nickname}}</span>
-          {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+          {{range .Badges}}<span class="post-badge">{{.}}</span>{{end}}
         </div>
         <time>{{datefmt .CreatedAt}}</time>
       </div>