仰望星辰工作室

feat(plugin): 新增背景音乐插件,插件系统支持音频上传与片段文件

F fqh 提交于 2026-09-13 23:31 · 23c754e ·父提交 3d65635
feat(plugin): 新增背景音乐插件,插件系统支持音频上传与片段文件

背景音乐插件(plugins/bgm)
- 管理员在后台「插件设置」上传音乐,访客打开站点即可听到
- 可配置:自动播放 / 循环 / 默认音量 / 悬浮按钮位置(右下、左下)
- 处理浏览器自动播放策略:被拦截时按钮旁提示「点击开启背景音乐」,
  并在访客首次交互时自动开始播放
- 访客开关记在 localStorage,播放进度记在 sessionStorage,
  站内翻页接着播、关闭状态不会因翻页丢失
- 悬浮按钮自动避开右下角的发帖按钮;音频加载失败时整体隐藏
- 复用规则守卫插件同款的悬浮按钮,不引入任何外部资源

插件系统增强
- html 钩子支持 {config:key} 展开(此前只有 guard / badge 支持),
  插件可把配置值(如上传的音乐地址)直接注入页面
- 新增 @片段文件:html 字段写 "@player.html" 即读取插件目录下的同名文件,
  带脚本的插件不必再在 JSON 里转义大段 HTML;只接受插件目录下的直接文件名,
  @../x 这类路径穿越会被无视,文件缺失时跳过该钩子
- 插件配置新增 audio 字段类型:后台自动渲染「试听 + 上传」控件,
  限 20MB 与扩展名白名单,文件名由服务端生成;重新上传或勾选删除会清理旧文件,
  配置值清除引号与尖括号(该值会被原样注入页面)

修复
- removePluginAsset 定位路径时漏掉 plugins 一段,导致重新上传后旧素材
  一直留在磁盘上(os.IsNotExist 分支吞掉了错误),已修正并补测试

测试
- plugin:@片段文件加载、{config:} 展开、未启用不注入、路径穿越防护
- handlers:素材清理路径、越权路径拒绝、素材地址清洗
- 端到端已跑通:上传 zip 安装 → 启用 → 上传音乐 → 前台注入播放器
  → 重新上传旧文件被清理 → 清除后文件归零且前台不再输出播放器
10 个文件变更 +602 -9 fangqihang1717@163.com
•README.md +19 -1
•docs/PLUGIN.md +41 -2
•internal/handlers/admin.go +103 -3
•internal/handlers/plugin_asset_test.go +74 -0
•internal/plugin/plugin.go +34 -2
•internal/plugin/plugin_test.go +115 -0
•plugins/bgm/player.html +147 -0
•plugins/bgm/plugin.json +48 -0
•web/static/app.css +6 -0
•web/templates/admin.html +15 -1
变更内容
diff --git a/README.md b/README.md
index c594d5d..30d73b8 100644
--- a/README.md
+++ b/README.md
@@ -415,6 +415,21 @@ docker run -d --name clearlove --restart=always \
 
 安装流程:拉取市场列表 → 下载 zip → **SHA256 完整性校验** → 落盘 → 回到插件管理页「**启用**」。
 
+### 内置插件
+
+| 插件 | 说明 | 安装方式 |
+|---|---|---|
+| [背景音乐](plugins/bgm) | 管理员上传音乐后,访客打开站点即可听到背景音乐 | 后台「插件管理」上传 `dist/plugin-bgm.zip` |
+
+**背景音乐**用法:上传 zip → 启用 → 在「插件设置」中上传音乐并保存。
+
+> zip 内 `plugin.json` 与 `player.html` 需位于**压缩包根目录**(不要再套一层文件夹)。
+> 仓库 `dist/` 目录不纳入版本库,可自行打包:`cd plugins/bgm && zip -r ../../plugin-bgm.zip .`
+
+> 浏览器会拦截带声音的自动播放,插件已做兜底:被拦截时播放按钮旁会出现「点击开启背景音乐」提示,
+> 访客点击一次即可播放;站内翻页会接着播(进度记在会话里),关闭状态也会被记住。
+> 支持自动播放 / 循环 / 默认音量(0-100)/ 悬浮按钮位置(右下 / 左下)设置,按钮会自动避开右下角的发帖按钮。
+
 ### 开发并发布插件
 
 插件就是一个包含 `plugin.json` 的 zip 包:
@@ -442,7 +457,10 @@ docker run -d --name clearlove --restart=always \
 | `badge` | `post_badge` | 给指定昵称的帖子打上自定义标识 |
 
 插件还可在 `config` 中**声明配置项**,后台「插件管理 → 插件设置」会自动渲染表单,
-钩子字段用 `{config:key}` 引用配置值(示例见 [plugins/official-guard](plugins/official-guard))。
+钩子字段用 `{config:key}` 引用配置值(`guard` / `badge` / `html` 钩子均支持)。
+
+配置项类型:`text` / `textarea` / `switch` / `select` / `number` / **`audio`(音频上传,含试听与旧文件自动清理)**。
+较长的 HTML / JS 片段可放在插件目录的独立文件中,`html` 字段写成 `"@player.html"` 即可,无需在 JSON 里转义。
 
 发布流程:在云端 <https://clearlove.kazx.top/dev/register> 注册开发者(邮箱验证)→ 开发者后台上传 zip → 管理员审核上架 → 所有站点即可一键安装。详见 [docs/PLUGIN.md](docs/PLUGIN.md)。
 
diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md
index df94029..0141d8c 100644
--- a/docs/PLUGIN.md
+++ b/docs/PLUGIN.md
@@ -77,14 +77,29 @@ your-plugin.zip
 |---|---|
 | `key` | 字段名,供 `{config:key}` 引用 |
 | `label` | 后台表单里显示的名称 |
-| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` |
+| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` / `audio`(音频上传) |
 | `default` | 未配置时的默认值 |
 | `help` | 表单下方的说明文字 |
 | `options` | 仅 `select` 使用,候选项数组 |
 
+### 上传型字段(audio)
+
+`type: "audio"` 会渲染成「试听 + 选择文件」的上传控件,管理员保存后配置值即为文件的可访问地址
+(形如 `/uploads/plugins/<插件名>/audio-xxxxxxxx.mp3`),可直接用于 `{config:key}`:
+
+```json
+{ "key": "music", "label": "背景音乐文件", "type": "audio",
+  "default": "", "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,单个文件不超过 20MB" }
+```
+
+- 文件名由服务端生成,不使用上传时的原始文件名
+- 限制 20MB 与扩展名白名单;重新上传或勾选「删除当前音乐」会自动清理旧文件
+- 配置值会被清除引号与尖括号等危险字符 —— 因为它会被原样注入页面
+
 ### 在钩子里引用配置
 
-任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值:
+任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值。
+`guard` / `badge` 与 `html` 类钩子都支持:
 
 ```json
 { "hook": "compose_guard", "type": "guard",
@@ -92,6 +107,11 @@ your-plugin.zip
   "label": "{config:badge}" }
 ```
 
+```json
+{ "hook": "footer_html", "type": "html",
+  "html": "<audio src=\"{config:music}\" autoplay loop></audio>" }
+```
+
 配置值保存在数据库 `settings` 表(键名 `plugin.<插件名>.<字段名>`),随站点一起备份。
 
 ---
@@ -124,6 +144,25 @@ your-plugin.zip
 注入的 HTML 会被原样输出到页面,因此**可以包含 `<style>` 与 `<script>`** —— 这也是插件实现
 自定义交互(弹窗、动效、统计)的主要方式。请勿注入恶意代码。
 
+### 片段较长时用 `@文件`
+
+在 JSON 里转义大段 HTML / JS 很痛苦。把片段放在插件目录下的独立文件里,
+`html` 字段写成 `@文件名` 即可,安装(zip)时会随插件一起落盘:
+
+```json
+{ "hook": "footer_html", "type": "html", "html": "@player.html" }
+```
+
+```
+插件 zip 根目录/
+├── plugin.json
+└── player.html      ← 与 plugin.json 同级,整段 HTML/CSS/JS 原样放在这里
+```
+
+- 只读取插件目录下的**直接文件名**,`@../xxx` 这类路径会被无视
+- 片段文件缺失时该钩子被跳过(不会把 `@player.html` 原样输出到页面)
+- 官方「背景音乐」插件就是这么写的,可直接参考 `plugins/bgm/`
+
 ### filter 钩子示例
 
 ```json
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index 10e1858..b75ab09 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -6,10 +6,12 @@ import (
 	"archive/zip"
 	"bytes"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"html/template"
 	"io"
 	"io/fs"
+	"mime/multipart"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -746,25 +748,56 @@ func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
 	if a := requireAdmin(w, r, models.PermPlugin); a == nil {
 		return
 	}
+	// 音频上传会把请求变成 multipart,这里限制总大小并显式解析
+	r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
+	if err := r.ParseMultipartForm(8 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
+		http.Error(w, "表单提交失败:文件超过 20MB 或格式错误", http.StatusBadRequest)
+		return
+	}
 	name := filepath.Base(r.FormValue("plugin"))
 	p, ok := plugin.Load()[name]
 	if !ok {
 		http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
 		return
 	}
+	old := plugin.ConfigOf(name)
 	values := map[string]string{}
 	for _, f := range p.Config {
 		if f.Key == "" {
 			continue
 		}
-		if f.Type == "switch" {
+		switch f.Type {
+		case "switch":
 			values[f.Key] = "0"
 			if r.FormValue("cfg_"+f.Key) == "1" {
 				values[f.Key] = "1"
 			}
-			continue
+		case "audio", "file":
+			// 优先用新上传的文件,其次是"删除",都没有则保留原值
+			var fh *multipart.FileHeader
+			if r.MultipartForm != nil {
+				if list := r.MultipartForm.File["cfg_"+f.Key+"__file"]; len(list) > 0 {
+					fh = list[0]
+				}
+			}
+			switch {
+			case fh != nil && fh.Size > 0:
+				url, err := savePluginAsset(name, fh)
+				if err != nil {
+					http.Error(w, "上传失败:"+err.Error(), http.StatusBadRequest)
+					return
+				}
+				removePluginAsset(old[f.Key])
+				values[f.Key] = url
+			case r.FormValue("cfg_"+f.Key+"__clear") == "1":
+				removePluginAsset(old[f.Key])
+				values[f.Key] = ""
+			default:
+				values[f.Key] = sanitizePluginAsset(r.FormValue("cfg_" + f.Key))
+			}
+		default:
+			values[f.Key] = r.FormValue("cfg_" + f.Key)
 		}
-		values[f.Key] = r.FormValue("cfg_" + f.Key)
 	}
 	if err := plugin.SaveConfig(p, values); err != nil {
 		http.Error(w, "保存失败: "+err.Error(), http.StatusInternalServerError)
@@ -774,6 +807,73 @@ func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
 }
 
+// pluginAssetExts 插件素材允许的音频扩展名
+var pluginAssetExts = map[string]bool{
+	".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".oga": true,
+	".opus": true, ".wav": true, ".flac": true, ".webm": true,
+}
+
+// savePluginAsset 保存插件上传的素材文件,返回可直接访问的 URL。
+// 文件名由服务端生成,不使用用户提交的文件名,避免路径与注入问题。
+func savePluginAsset(pluginName string, fh *multipart.FileHeader) (string, error) {
+	if fh.Size > 20<<20 {
+		return "", fmt.Errorf("文件不能超过 20MB")
+	}
+	ext := strings.ToLower(filepath.Ext(fh.Filename))
+	if !pluginAssetExts[ext] {
+		return "", fmt.Errorf("不支持的音频格式(支持 mp3 / m4a / aac / ogg / opus / wav / flac)")
+	}
+	sub := filepath.Base(pluginName)
+	dir := filepath.Join(config.Cfg.UploadDir, "plugins", sub)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return "", err
+	}
+	src, err := fh.Open()
+	if err != nil {
+		return "", err
+	}
+	defer src.Close()
+	name := "audio-" + util.RandomHex(8) + ext
+	dst, err := os.Create(filepath.Join(dir, name))
+	if err != nil {
+		return "", err
+	}
+	defer dst.Close()
+	if _, err := io.Copy(dst, io.LimitReader(src, 20<<20)); err != nil {
+		return "", err
+	}
+	return "/uploads/plugins/" + sub + "/" + name, nil
+}
+
+// removePluginAsset 删除本插件此前上传的素材(重新上传或清除时调用)。
+// 只处理 /uploads/plugins/ 下的路径,杜绝借配置值删除其他文件。
+func removePluginAsset(url string) {
+	const prefix = "/uploads/plugins/"
+	url = strings.TrimSpace(url)
+	if !strings.HasPrefix(url, prefix) || strings.Contains(url, "..") {
+		return
+	}
+	// /uploads/plugins/bgm/x.mp3 -> <UploadDir>/plugins/bgm/x.mp3
+	path := filepath.Join(config.Cfg.UploadDir, "plugins",
+		filepath.FromSlash(strings.TrimPrefix(url, prefix)))
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		util.Log("warn", "清理旧素材失败 %s: %v", path, err)
+	}
+}
+
+// sanitizePluginAsset 清理素材地址中的危险字符。
+// 该值会被插件以 {config:key} 原样注入页面,必须保证不含引号与尖括号。
+func sanitizePluginAsset(v string) string {
+	v = strings.TrimSpace(util.StripHTML(v))
+	return strings.Map(func(r rune) rune {
+		switch r {
+		case '"', '\'', '<', '>', '\\', '`':
+			return -1
+		}
+		return r
+	}, v)
+}
+
 // AdminPluginUpload 上传安装插件 zip
 func AdminPluginUpload(w http.ResponseWriter, r *http.Request) {
 	if a := requireAdmin(w, r, models.PermPlugin); a == nil {
diff --git a/internal/handlers/plugin_asset_test.go b/internal/handlers/plugin_asset_test.go
new file mode 100644
index 0000000..7f7f58c
--- /dev/null
+++ b/internal/handlers/plugin_asset_test.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"clearlove/internal/config"
+)
+
+// TestRemovePluginAsset 素材清理必须定位到 <UploadDir>/plugins/ 下的真实文件。
+// 曾经漏掉 plugins 一段,导致重新上传后旧文件一直留在磁盘上。
+func TestRemovePluginAsset(t *testing.T) {
+	root := t.TempDir()
+	config.Cfg.UploadDir = filepath.Join(root, "uploads")
+	dir := filepath.Join(config.Cfg.UploadDir, "plugins", "bgm")
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	target := filepath.Join(dir, "audio-abc.mp3")
+	if err := os.WriteFile(target, []byte("music"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	removePluginAsset("/uploads/plugins/bgm/audio-abc.mp3")
+	if _, err := os.Stat(target); !os.IsNotExist(err) {
+		t.Fatalf("旧素材未被删除: %v", err)
+	}
+
+	// 重复删除不应报错,也不应影响其他文件
+	removePluginAsset("/uploads/plugins/bgm/audio-abc.mp3")
+}
+
+// TestRemovePluginAssetGuards 只清理插件素材目录内的文件,拒绝越权路径
+func TestRemovePluginAssetGuards(t *testing.T) {
+	root := t.TempDir()
+	config.Cfg.UploadDir = filepath.Join(root, "uploads")
+	if err := os.MkdirAll(config.Cfg.UploadDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	keep := filepath.Join(config.Cfg.UploadDir, "keep.webp")
+	if err := os.WriteFile(keep, []byte("keep"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, u := range []string{
+		"",
+		"/uploads/keep.webp",                   // 不在插件目录内
+		"/uploads/plugins/../../keep.webp",     // 路径穿越
+		"/uploads/plugins/",                    // 目录本身
+		"https://example.com/x.mp3",            // 外部地址
+		"/uploads/plugins/../../../etc/passwd", // 绝对越权
+	} {
+		removePluginAsset(u)
+	}
+	if _, err := os.Stat(keep); err != nil {
+		t.Fatalf("不应删除插件素材目录之外的文件: %v", err)
+	}
+}
+
+// TestSanitizePluginAsset 素材地址要能安全地注入页面(不含引号与尖括号)
+func TestSanitizePluginAsset(t *testing.T) {
+	cases := map[string]string{
+		`/uploads/plugins/bgm/a.mp3`:   `/uploads/plugins/bgm/a.mp3`,
+		`/uploads/plugins/bgm/a'.mp3`:  `/uploads/plugins/bgm/a.mp3`,
+		`"/uploads/plugins/bgm/a.mp3"`: `/uploads/plugins/bgm/a.mp3`,
+		`x"><script>alert(1)</script>`: `xalert(1)`, // StripHTML 去掉标签,尖括号与引号也会被清除
+	}
+	for in, want := range cases {
+		if got := sanitizePluginAsset(in); got != want {
+			t.Errorf("sanitizePluginAsset(%q) = %q, 期望 %q", in, got, want)
+		}
+	}
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index e0c24d7..2657ebe 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -137,12 +137,41 @@ func Load() map[string]*Plugin {
 		}
 		var p Plugin
 		if json.Unmarshal(b, &p) == nil && p.Name != "" {
+			loadHookFiles(&p, filepath.Join(dir(), e.Name()))
 			out[p.Name] = &p
 		}
 	}
 	return out
 }
 
+// loadHookFiles 支持把 html 片段放在插件目录的文件中:
+//
+//	{"hook":"footer_html","type":"html","html":"@player.html"}
+//
+// 表示读取同目录下的 player.html。带脚本的插件用它可以避免在 JSON 里
+// 转义大段 HTML,插件安装(zip)时会连同该文件一起落盘。
+func loadHookFiles(p *Plugin, base string) {
+	for i := range p.Hooks {
+		h := &p.Hooks[i]
+		if h.Type != "html" || !strings.HasPrefix(h.HTML, "@") {
+			continue
+		}
+		// 只允许插件目录下的直接文件名,避免路径穿越
+		name := filepath.Base(strings.TrimSpace(h.HTML[1:]))
+		if name == "" || name == "." || name == string(filepath.Separator) {
+			h.HTML = ""
+			continue
+		}
+		b, err := os.ReadFile(filepath.Join(base, name))
+		if err != nil {
+			// 文件缺失时清空,避免把 "@xxx" 原样注入页面
+			h.HTML = ""
+			continue
+		}
+		h.HTML = string(b)
+	}
+}
+
 // enabledSet 已启用插件名集合
 func enabledSet() map[string]bool {
 	set := map[string]bool{}
@@ -474,7 +503,9 @@ func CallFilter(content string) string {
 	return content
 }
 
-// CallHTML 收集指定页面钩子点的全部启用插件 HTML 片段
+// CallHTML 收集指定页面钩子点的全部启用插件 HTML 片段,
+// 并展开片段中的 {config:key} 占位符(与 guard / badge 钩子保持一致),
+// 插件据此把后台配置好的值(如上传的音乐地址)注入页面。
 func CallHTML(hook string) string {
 	var b strings.Builder
 	enabled := enabledSet()
@@ -482,9 +513,10 @@ func CallHTML(hook string) string {
 		if !enabled[name] {
 			continue
 		}
+		cfg := GetConfig(p)
 		for _, h := range p.Hooks {
 			if h.Hook == hook && h.Type == "html" {
-				b.WriteString(h.HTML)
+				b.WriteString(expand(h.HTML, cfg))
 			}
 		}
 	}
diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go
new file mode 100644
index 0000000..78e108b
--- /dev/null
+++ b/internal/plugin/plugin_test.go
@@ -0,0 +1,115 @@
+package plugin
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"clearlove/internal/config"
+	"clearlove/internal/database"
+)
+
+// setup 准备带数据库的插件测试环境(插件配置保存在 settings 表)
+func setup(t *testing.T) string {
+	t.Helper()
+	root := t.TempDir()
+	dataDir := filepath.Join(root, "data")
+	if err := os.MkdirAll(dataDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	config.Cfg = config.Config{
+		DataDir:    dataDir,
+		UploadDir:  filepath.Join(root, "uploads"),
+		DBType:     "sqlite",
+		SQLitePath: filepath.Join(dataDir, "clearlove.db"),
+		Installed:  true,
+		Secret:     "test-secret",
+	}
+	if err := database.Connect(); err != nil {
+		t.Fatalf("数据库连接失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if database.DB != nil {
+			_ = database.DB.Close()
+			database.DB = nil
+		}
+	})
+	if err := database.Migrate(); err != nil {
+		t.Fatalf("数据库建表失败: %v", err)
+	}
+	return dataDir
+}
+
+// writePlugin 写入一个插件目录
+func writePlugin(t *testing.T, dataDir, name, manifest, fragment string) string {
+	t.Helper()
+	d := filepath.Join(dataDir, "plugins", name)
+	if err := os.MkdirAll(d, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(manifest), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if fragment != "" {
+		if err := os.WriteFile(filepath.Join(d, "player.html"), []byte(fragment), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	return d
+}
+
+// TestCallHTMLFileAndConfig 验证 html 钩子:@片段文件加载、{config:} 展开与启用开关
+func TestCallHTMLFileAndConfig(t *testing.T) {
+	dataDir := setup(t)
+	writePlugin(t, dataDir, "demo", `{
+		"name":"demo","version":"1.0.0",
+		"config":[{"key":"music","default":"/uploads/default.mp3"}],
+		"hooks":[{"hook":"footer_html","type":"html","html":"@player.html"}]
+	}`, `<audio src="{config:music}"></audio>`)
+
+	// 未启用:不注入任何内容
+	if got := CallHTML("footer_html"); got != "" {
+		t.Fatalf("未启用的插件不应注入内容, got %q", got)
+	}
+
+	SetEnabled("demo", true)
+	if got := CallHTML("footer_html"); got != `<audio src="/uploads/default.mp3"></audio>` {
+		t.Fatalf("默认配置未展开: %q", got)
+	}
+
+	p := Load()["demo"]
+	if p == nil {
+		t.Fatal("插件未被加载")
+	}
+	if err := SaveConfig(p, map[string]string{"music": "/uploads/plugins/demo/audio-abc.mp3"}); err != nil {
+		t.Fatal(err)
+	}
+	if got := CallHTML("footer_html"); !strings.Contains(got, "/uploads/plugins/demo/audio-abc.mp3") {
+		t.Fatalf("配置值未注入页面: %q", got)
+	}
+
+	// 片段文件缺失时应跳过该钩子,而不是把 "@player.html" 原样注入页面
+	if err := os.Remove(filepath.Join(dataDir, "plugins", "demo", "player.html")); err != nil {
+		t.Fatal(err)
+	}
+	if got := CallHTML("footer_html"); strings.Contains(got, "@player.html") {
+		t.Fatalf("片段缺失时不应注入占位文本: %q", got)
+	}
+}
+
+// TestHookFragmentTraversal @片段文件不允许路径穿越
+func TestHookFragmentTraversal(t *testing.T) {
+	dataDir := setup(t)
+	if err := os.WriteFile(filepath.Join(dataDir, "secret.txt"), []byte("TOP-SECRET"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	writePlugin(t, dataDir, "evil", `{
+		"name":"evil","version":"1.0.0",
+		"hooks":[{"hook":"footer_html","type":"html","html":"@../secret.txt"}]
+	}`, "")
+	SetEnabled("evil", true)
+	if got := CallHTML("footer_html"); strings.Contains(got, "TOP-SECRET") {
+		t.Fatalf("存在路径穿越读取: %q", got)
+	}
+}
diff --git a/plugins/bgm/player.html b/plugins/bgm/player.html
new file mode 100644
index 0000000..a1a6709
--- /dev/null
+++ b/plugins/bgm/player.html
@@ -0,0 +1,147 @@
+<!-- ClearLove 背景音乐播放器(由插件 bgm 注入到 footer_html) -->
+<div id="clv-bgm" hidden
+     data-src="{config:music}"
+     data-vol="{config:volume}"
+     data-auto="{config:autoplay}"
+     data-loop="{config:loop}"
+     data-pos="{config:pos}">
+  <audio id="clv-bgm-audio" preload="auto" playsinline></audio>
+  <button id="clv-bgm-btn" type="button" aria-label="播放背景音乐" aria-pressed="false">
+    <svg class="bgm-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 17.5V5.8l10-2v11.7"/><circle cx="6.6" cy="17.8" r="2.6"/><circle cx="16.6" cy="15.5" r="2.6"/></svg>
+    <svg class="bgm-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 17.5V5.8l10-2v11.7"/><circle cx="6.6" cy="17.8" r="2.6"/><circle cx="16.6" cy="15.5" r="2.6"/><path d="M3.5 3.5l17 17"/></svg>
+  </button>
+  <span id="clv-bgm-tip" hidden>点击开启背景音乐</span>
+</div>
+<style>
+#clv-bgm{position:fixed;z-index:900;bottom:22px;display:flex;align-items:center;gap:8px;animation:clv-bgm-in .45s ease both}
+#clv-bgm[hidden]{display:none!important}
+#clv-bgm.pos-right{right:18px;flex-direction:row-reverse}
+#clv-bgm.pos-left{left:18px}
+#clv-bgm-btn{width:46px;height:46px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:50%;cursor:pointer;color:#fff;background:linear-gradient(135deg,var(--clv-accent,#ff7a9c),var(--clv-accent-2,#8a6cff));box-shadow:0 8px 22px rgba(0,0,0,.22)}
+#clv-bgm-btn:focus-visible{outline:2px solid var(--clv-accent);outline-offset:2px}
+#clv-bgm-btn svg{width:22px;height:22px}
+#clv-bgm-btn .bgm-off{display:none}
+#clv-bgm.paused #clv-bgm-btn .bgm-on{display:none}
+#clv-bgm.paused #clv-bgm-btn .bgm-off{display:block}
+#clv-bgm.playing #clv-bgm-btn{animation:clv-bgm-pulse 2.4s ease-in-out infinite}
+#clv-bgm-tip{font-size:12px;padding:5px 10px;border-radius:999px;white-space:nowrap;background:var(--clv-card,#fff);color:var(--clv-text,#23262f);border:1px solid var(--clv-border,rgba(0,0,0,.1));box-shadow:0 6px 16px rgba(0,0,0,.12)}
+@keyframes clv-bgm-in{from{opacity:0}to{opacity:1}}
+@keyframes clv-bgm-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
+@media (prefers-reduced-motion:reduce){#clv-bgm.playing #clv-bgm-btn{animation:none}}
+@media (max-width:480px){#clv-bgm-btn{width:42px;height:42px}}
+</style>
+<script>
+(function () {
+  var box = document.getElementById('clv-bgm');
+  if (!box) return;
+  var audio = document.getElementById('clv-bgm-audio');
+  var btn = document.getElementById('clv-bgm-btn');
+  var tip = document.getElementById('clv-bgm-tip');
+  var src = (box.getAttribute('data-src') || '').trim();
+  if (!src) return; // 未上传音乐:播放器不出现
+
+  audio.src = src;
+  audio.loop = box.getAttribute('data-loop') === '1';
+  var v = parseInt(box.getAttribute('data-vol'), 10);
+  audio.volume = Math.min(1, Math.max(0, (isNaN(v) ? 35 : v) / 100));
+  box.className = box.getAttribute('data-pos') === '左下' ? 'pos-left' : 'pos-right';
+
+  var ON = 'clv_bgm_on', POS = 'clv_bgm_pos';
+  function get(k, sess) { try { return (sess ? sessionStorage : localStorage).getItem(k); } catch (e) { return null; } }
+  function set(k, val, sess) { try { (sess ? sessionStorage : localStorage).setItem(k, val); } catch (e) { } }
+
+  // 访客的开关优先,其次才是后台的「自动播放」设置
+  var saved = get(ON);
+  var on = saved === null ? box.getAttribute('data-auto') === '1' : saved === '1';
+  var armed = false, tipTimer = null;
+
+  function render() {
+    var playing = !audio.paused && !audio.ended;
+    box.classList.toggle('playing', playing);
+    box.classList.toggle('paused', !playing);
+    btn.setAttribute('aria-pressed', playing ? 'true' : 'false');
+    btn.setAttribute('aria-label', playing ? '暂停背景音乐' : '播放背景音乐');
+  }
+
+  // 浏览器要求音频必须由用户手势触发:监听一次全局交互再播
+  function armGesture() {
+    if (armed) return;
+    armed = true;
+    var fire = function () {
+      document.removeEventListener('pointerdown', fire, true);
+      document.removeEventListener('keydown', fire, true);
+      armed = false;
+      if (on && audio.paused) play(true);
+    };
+    document.addEventListener('pointerdown', fire, true);
+    document.addEventListener('keydown', fire, true);
+  }
+
+  function showTip() {
+    tip.hidden = false;
+    clearTimeout(tipTimer);
+    tipTimer = setTimeout(function () { tip.hidden = true; }, 10000);
+  }
+
+  function play(byGesture) {
+    var p = audio.play();
+    if (p && p.catch) {
+      p.catch(function () {
+        // 自动播放被拦截:提示访客,并在其首次交互时自动开始
+        if (!byGesture) { showTip(); armGesture(); }
+        render();
+      });
+    }
+  }
+
+  btn.addEventListener('click', function () {
+    if (audio.paused) {
+      on = true;
+      set(ON, '1');
+      tip.hidden = true;
+      play(true);
+    } else {
+      on = false;
+      set(ON, '0');
+      audio.pause();
+    }
+    render();
+  });
+
+  audio.addEventListener('play', render);
+  audio.addEventListener('pause', render);
+  // 文件缺失或格式不支持时直接隐藏,避免留下一个点不动的按钮
+  audio.addEventListener('error', function () { box.hidden = true; });
+
+  // 记录播放进度,站内翻页后接着播
+  var lastSave = 0;
+  audio.addEventListener('timeupdate', function () {
+    if (Math.abs(audio.currentTime - lastSave) >= 1) {
+      lastSave = audio.currentTime;
+      set(POS, String(audio.currentTime), true);
+    }
+  });
+  window.addEventListener('pagehide', function () { set(POS, String(audio.currentTime), true); });
+  var resume = parseFloat(get(POS, true));
+  if (isFinite(resume) && resume > 0) {
+    audio.addEventListener('loadedmetadata', function () {
+      try { if (!audio.duration || resume < audio.duration) audio.currentTime = resume; } catch (e) { }
+    });
+  }
+
+  // 自动避开右下角的发帖悬浮按钮,避免两个按钮叠在一起
+  function place() {
+    var fab = document.querySelector('.fab');
+    var right = box.classList.contains('pos-right');
+    var r = fab ? fab.getBoundingClientRect() : null;
+    var sameSide = r && r.width > 0 && (right ? window.innerWidth - r.right < 90 : r.left < 90);
+    box.style.bottom = sameSide ? Math.round(window.innerHeight - r.top + 12) + 'px' : '';
+  }
+
+  box.hidden = false;
+  place();
+  window.addEventListener('resize', place);
+  render();
+  if (on) play(false);
+})();
+</script>
diff --git a/plugins/bgm/plugin.json b/plugins/bgm/plugin.json
new file mode 100644
index 0000000..f25e557
--- /dev/null
+++ b/plugins/bgm/plugin.json
@@ -0,0 +1,48 @@
+{
+  "name": "bgm",
+  "version": "1.0.0",
+  "author": "ClearLove",
+  "description": "背景音乐:管理员上传音乐后,访客打开站点即可听到背景音乐,支持自动播放、循环、音量与按钮位置设置",
+  "requires": "2.0.0",
+  "config": [
+    {
+      "key": "music",
+      "label": "背景音乐文件",
+      "type": "audio",
+      "default": "",
+      "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,单个文件不超过 20MB;重新上传会自动删除旧文件"
+    },
+    {
+      "key": "autoplay",
+      "label": "自动播放",
+      "type": "switch",
+      "default": "1",
+      "help": "受浏览器自动播放策略限制:被拦截时按钮旁会出现提示,访客点击一次即可播放"
+    },
+    {
+      "key": "loop",
+      "label": "循环播放",
+      "type": "switch",
+      "default": "1",
+      "help": "关闭后音乐播放完自动停止"
+    },
+    {
+      "key": "volume",
+      "label": "默认音量(0-100)",
+      "type": "number",
+      "default": "35",
+      "help": "访客可通过悬浮按钮自行开关,其选择会被记住"
+    },
+    {
+      "key": "pos",
+      "label": "悬浮按钮位置",
+      "type": "select",
+      "default": "右下",
+      "options": ["右下", "左下"],
+      "help": "按钮会自动避开同侧右下角的发帖悬浮按钮"
+    }
+  ],
+  "hooks": [
+    { "hook": "footer_html", "type": "html", "html": "@player.html" }
+  ]
+}
diff --git a/web/static/app.css b/web/static/app.css
index 6d9eead..3c5fc4c 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -369,6 +369,12 @@ textarea { resize: vertical; }
 .settings-form .card { padding: 18px; }
 .check-row { display: flex; align-items: center; gap: 8px; }
 .check-row input { width: auto; margin: 0; }
+/* ---------------- 插件设置:上传型字段 ---------------- */
+.plugin-audio { margin: 10px 0; display: flex; flex-direction: column; gap: 8px; }
+.plugin-audio .field-label { font-size: 14px; color: #4b5060; }
+.plugin-audio-prev { width: 100%; max-width: 420px; height: 36px; }
+.plugin-audio input[type=file] { font-size: 13px; }
+
 /* ---------------- 数据迁移 ---------------- */
 .tips-list { margin: 6px 0 0; padding-left: 20px; font-size: 13px; color: #4b5060; line-height: 1.9; }
 .tips-list li { margin-bottom: 2px; }
diff --git a/web/templates/admin.html b/web/templates/admin.html
index 0a6430a..b6ee47f 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -470,12 +470,25 @@
 
 {{range .plugins}}
 {{if .Config}}
-<form class="card plugin-config" method="post" action="/admin/plugins/config">
+<form class="card plugin-config" method="post" action="/admin/plugins/config" enctype="multipart/form-data">
   <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}}
+  {{if eq .Type "audio"}}
+  {{/* 上传型字段:不套 label,避免点击试听控件时触发文件选择 */}}
+  {{$audio := index $cfg .Key}}
+  <div class="plugin-audio">
+    <span class="field-label">{{.Label}}</span>
+    {{if $audio}}
+    <audio src="{{$audio}}" controls preload="none" class="plugin-audio-prev"></audio>
+    <label class="check-row"><input type="checkbox" name="cfg_{{.Key}}__clear" value="1"> 删除当前音乐</label>
+    {{else}}<span class="muted small">尚未上传</span>{{end}}
+    <input type="file" name="cfg_{{.Key}}__file" accept="audio/*,.mp3,.m4a,.aac,.ogg,.oga,.opus,.wav,.flac">
+    <input type="hidden" name="cfg_{{.Key}}" value="{{$audio}}">
+  </div>
+  {{else}}
   <label>{{.Label}}
     {{if eq .Type "textarea"}}
     <textarea name="cfg_{{.Key}}" rows="3" placeholder="{{.Default}}">{{index $cfg .Key}}</textarea>
@@ -488,6 +501,7 @@
     <input type="text" name="cfg_{{.Key}}" value="{{index $cfg .Key}}" placeholder="{{.Default}}">
     {{end}}
   </label>
+  {{end}}
   {{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>