Compare commits

...

6 Commits

Author SHA1 Message Date
b997528bad docs: README 注明维护暂停状态
Some checks failed
CI / Lint + Build (push) Has been cancelled
2026-06-30 19:16:23 +08:00
426bedda65 fix: ComputeAssetHashes 并发安全 + theme 页重复加载 JS 导致双写 panic
Some checks failed
CI / Lint + Build (push) Has been cancelled
- helper.go: 用 sync.RWMutex 保护 assetHashes,构建本地 map 后原子替换
- theme.html: 删除硬编码 script 标签(footer ExtraJS 已加载),
  修复 site-settings-theme.js 执行两次 → applyTheme 触发两次 → 并发写 map panic
2026-06-30 19:10:11 +08:00
d4a833f149 fix(admin): security 页 password.strength 下拉未从 API 恢复当前值 2026-06-30 19:08:11 +08:00
3f4d827926 fix(admin): 注册设置 toggle 读错数据源 — 从 audit 读 registration.* 导致状态丢失 2026-06-30 19:06:37 +08:00
1eb3cc7c86 debug: 添加站点设置持久化调试日志,追踪 Set/Get 链路
Some checks failed
CI / Lint + Build (push) Has been cancelled
2026-06-30 19:01:25 +08:00
20067f7640 fix(admin): 修复多页面设置加载失败 — renderOtherSettings 空指针崩溃
Some checks failed
CI / Lint + Build (push) Has been cancelled
根因:site-settings.js 的 renderOtherSettings() 无条件访问
#general-settings 元素,但 brand/security/registration 三个模板
无此容器,导致 TypeError 被 catch 吞掉 → '加载设置失败'。

额外修复:
- footer.html: ExtraJS 添加 ?v= 缓存版本哈希
- site-settings.js: API 失败时显式展示服务端错误消息(不再静默 return)
- site_settings.go: Set() 改用 UPSERT(clause.OnConflict),
  修复 GORM Save() 对不存在 key 首次保存时返回 ErrRecordNotFound
2026-06-30 18:56:42 +08:00
8 changed files with 58 additions and 18 deletions

View File

@ -11,6 +11,10 @@
<p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p> <p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p>
> **⚠️ 维护状态**
> 因个人原因本项目暂时无法继续活跃维护。现有功能可正常使用Issues/PR 可能无法及时响应。
> 如有兴趣接手或继续开发,欢迎 Fork。
--- ---
## 关于项目 ## 关于项目

View File

@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"sync"
"time" "time"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
@ -19,22 +20,31 @@ import (
) )
// assetHashes 静态资源文件哈希表URL 路径 → 短哈希,文件不变哈希不变) // assetHashes 静态资源文件哈希表URL 路径 → 短哈希,文件不变哈希不变)
var assetHashes map[string]string var (
assetHashes map[string]string
assetMu sync.RWMutex
)
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号 // ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
// 构建本地 map 后原子替换,避免并发读写冲突
func ComputeAssetHashes(templateDir string) { func ComputeAssetHashes(templateDir string) {
assetHashes = make(map[string]string) mu := make(map[string]string)
// 前端主题templates/MetaLab-2026/static → URL /static/ // 前端主题templates/MetaLab-2026/static → URL /static/
walkStaticDir(filepath.Join(templateDir, "static"), "/static") walkStaticDirInto(mu, filepath.Join(templateDir, "static"), "/static")
// 共享资源templates/shared/static → URL /shared/static/ // 共享资源templates/shared/static → URL /shared/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static") walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
// 管理后台templates/admin/static → URL /admin/static/ // 管理后台templates/admin/static → URL /admin/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static") walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
assetMu.Lock()
assetHashes = mu
assetMu.Unlock()
log.Printf("[AssetHashes] computed %d file hashes", len(mu))
} }
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes // walkStaticDirInto 遍历目录,计算每个 .js/.css 的 CRC32存入目标 map
func walkStaticDir(dir, urlPrefix string) { func walkStaticDirInto(dst map[string]string, dir, urlPrefix string) {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
return nil return nil
@ -52,14 +62,17 @@ func walkStaticDir(dir, urlPrefix string) {
// 生成 URL 路径urlPrefix + 相对于 dir 的子路径 // 生成 URL 路径urlPrefix + 相对于 dir 的子路径
rel, _ := filepath.Rel(dir, path) rel, _ := filepath.Rel(dir, path)
urlPath := urlPrefix + "/" + filepath.ToSlash(rel) urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
assetHashes[urlPath] = shortHash dst[urlPath] = shortHash
return nil return nil
}) })
} }
// AssetV 返回指定静态资源的哈希版本号(模板函数) // AssetV 返回指定静态资源的哈希版本号(模板函数,并发安全
func AssetV(path string) string { func AssetV(path string) string {
if h, ok := assetHashes[path]; ok { assetMu.RLock()
h, ok := assetHashes[path]
assetMu.RUnlock()
if ok {
return h return h
} }
return "0" return "0"

View File

@ -1,12 +1,14 @@
package config package config
import ( import (
"log"
"strconv" "strconv"
"sync" "sync"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// SiteSettings 站点设置管理器DB 持久化 + 内存缓存,不重启生效) // SiteSettings 站点设置管理器DB 持久化 + 内存缓存,不重启生效)
@ -69,15 +71,22 @@ func (s *SiteSettings) GetBool(key string, defaultVal bool) bool {
return b return b
} }
// Set 写入设置(同步写 DB + 更新内存) // Set 写入设置(同步写 DB + 更新内存,使用 UPSERT 兼容首次保存
func (s *SiteSettings) Set(key, value string) error { func (s *SiteSettings) Set(key, value string) error {
entry := model.SiteSetting{Key: key, Value: value} entry := model.SiteSetting{Key: key, Value: value}
if err := s.db.Save(&entry).Error; err != nil { result := s.db.Clauses(clause.OnConflict{
return err Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&entry)
if result.Error != nil {
log.Printf("[SiteSettings] Set FAIL key=%s value=%s err=%v", key, value, result.Error)
return result.Error
} }
s.mu.Lock() s.mu.Lock()
oldVal, existed := s.data[key]
s.data[key] = value s.data[key] = value
s.mu.Unlock() s.mu.Unlock()
log.Printf("[SiteSettings] Set OK key=%s old=%q new=%q existed=%v rows=%d", key, oldVal, value, existed, result.RowsAffected)
return nil return nil
} }

View File

@ -1,6 +1,7 @@
package admin package admin
import ( import (
"log"
"net/http" "net/http"
"strconv" "strconv"
@ -87,6 +88,10 @@ func (sc *SiteSettingController) ThemePage(c *gin.Context) {
func (sc *SiteSettingController) GetSettings(c *gin.Context) { func (sc *SiteSettingController) GetSettings(c *gin.Context) {
all := sc.settingService.GetSettings() all := sc.settingService.GetSettings()
audit := sc.settingService.GetAuditSettings(sc.defaults) audit := sc.settingService.GetAuditSettings(sc.defaults)
log.Printf("[GetSettings] all=%d keys audit=%+v", len(all), audit)
if v, ok := all["registration.enabled"]; ok {
log.Printf("[GetSettings] registration.enabled=%q", v)
}
common.Ok(c, gin.H{ common.Ok(c, gin.H{
"all": all, "all": all,
"audit": audit, "audit": audit,

BIN
main Executable file

Binary file not shown.

View File

@ -3,7 +3,7 @@
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script> <script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script> <script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
{{if .ExtraJS}} {{if .ExtraJS}}
<script src="{{.ExtraJS}}" nonce="{{.CSPNonce}}"></script> <script src="{{.ExtraJS}}?v={{assetV .ExtraJS}}" nonce="{{.CSPNonce}}"></script>
{{end}} {{end}}
</body> </body>
</html> </html>

View File

@ -41,4 +41,3 @@
</div> </div>
</div> </div>
{{template "admin/layout/footer.html" .}} {{template "admin/layout/footer.html" .}}
<script src="/admin/static/js/site-settings-theme.js?v={{assetV "/admin/static/js/site-settings-theme.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -36,7 +36,10 @@
async function loadSettings() { async function loadSettings() {
try { try {
var res = await api.get('/api/admin/site-settings'); var res = await api.get('/api/admin/site-settings');
if (!res.success) return; if (!res.success) {
showToast(res.message || '加载设置失败', 'error');
return;
}
// 所有开关 // 所有开关
var audit = res.data.audit || {}; var audit = res.data.audit || {};
@ -47,7 +50,7 @@
if (t.id === 'maintenance-enabled') { if (t.id === 'maintenance-enabled') {
el.checked = allSettings[t.key] === 'true'; el.checked = allSettings[t.key] === 'true';
} else if (t.id === 'registration-enabled') { } else if (t.key.indexOf('registration.') === 0) {
el.checked = allSettings[t.key] !== 'false'; el.checked = allSettings[t.key] !== 'false';
} else { } else {
el.checked = audit[t.key.split('.')[1]] === true; el.checked = audit[t.key.split('.')[1]] === true;
@ -60,6 +63,12 @@
// 文本输入保存按钮 // 文本输入保存按钮
bindSaveButtons(); bindSaveButtons();
// 密码强度下拉(服务端渲染默认值,需从 API 覆盖以显示最新保存值)
var strengthSelect = document.getElementById('setting-password-strength');
if (strengthSelect && allSettings['password.strength']) {
strengthSelect.value = allSettings['password.strength'];
}
// 社区准则文本域 // 社区准则文本域
var guidelinesEl = document.getElementById('setting-site-guidelines'); var guidelinesEl = document.getElementById('setting-site-guidelines');
if (guidelinesEl) { if (guidelinesEl) {
@ -138,6 +147,7 @@
// --- 渲染其他设置过滤已有专属UI的key --- // --- 渲染其他设置过滤已有专属UI的key ---
function renderOtherSettings(all) { function renderOtherSettings(all) {
var container = document.getElementById('general-settings'); var container = document.getElementById('general-settings');
if (!container) return; // 部分页面无此容器
var filtered = []; var filtered = [];
Object.keys(all).forEach(function(k) { Object.keys(all).forEach(function(k) {
if (!DEDICATED_KEYS[k]) filtered.push(k); if (!DEDICATED_KEYS[k]) filtered.push(k);