feat: add platform configuration tables and admin settings
Add four DB-backed configuration tables for site-wide settings: site_settings (logo, top-left title, header/footer text with zh/en variants), upload_configs (master switch, default size, storage dir), upload_file_types (per-extension whitelist with per-type size limits), and download_baseurls (multiple download sources with priority/default). - Models, AutoMigrate, and first-run seed (18 common file types) - Process-level config cache warmed at startup, refreshed on admin save - SetUserContext injects cached site settings into templates - base.html renders logo (local upload or external URL), title, header banner, and footer with i18n fallback - Upload validator drives avatar uploads (form + AJAX) through the configured switch/type/size policy - Admin pages for site/upload/download settings with i18n keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// configCache holds process-level caches of platform configuration so that
|
||||
// per-request rendering and upload validation do not hit the database. The
|
||||
// cache is populated once at startup and refreshed whenever an admin saves a
|
||||
// settings page (see RefreshConfigCache / the admin handlers).
|
||||
var configCache = struct {
|
||||
mu sync.RWMutex
|
||||
site *SiteSetting
|
||||
upload *UploadConfig
|
||||
types []UploadFileType
|
||||
baseURLs []DownloadBaseURL
|
||||
}{
|
||||
site: &SiteSetting{},
|
||||
upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"},
|
||||
}
|
||||
|
||||
// LoadConfigCache reads all platform configuration from the database into the
|
||||
// process cache. Called once at startup after InitDB.
|
||||
func LoadConfigCache(db *gorm.DB) {
|
||||
configCache.mu.Lock()
|
||||
defer configCache.mu.Unlock()
|
||||
|
||||
var s SiteSetting
|
||||
if err := db.First(&s, 1).Error; err == nil {
|
||||
configCache.site = &s
|
||||
} else {
|
||||
configCache.site = &SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
var u UploadConfig
|
||||
if err := db.First(&u, 1).Error; err == nil {
|
||||
configCache.upload = &u
|
||||
} else {
|
||||
configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}
|
||||
}
|
||||
|
||||
var t []UploadFileType
|
||||
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
|
||||
configCache.types = t
|
||||
}
|
||||
|
||||
var b []DownloadBaseURL
|
||||
if err := db.Order("is_default desc, priority asc, id asc").Find(&b).Error; err == nil {
|
||||
configCache.baseURLs = b
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
|
||||
// call this after writing changes so the next request sees them.
|
||||
func RefreshConfigCache(db *gorm.DB) {
|
||||
LoadConfigCache(db)
|
||||
}
|
||||
|
||||
// GetSiteSetting returns a pointer to the cached site settings (read-only copy
|
||||
// semantics: callers must not mutate).
|
||||
func GetSiteSetting() *SiteSetting {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.site
|
||||
}
|
||||
|
||||
// GetUploadConfig returns the cached upload policy.
|
||||
func GetUploadConfig() *UploadConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.upload
|
||||
}
|
||||
|
||||
// GetUploadFileTypes returns the cached list of permitted file types.
|
||||
func GetUploadFileTypes() []UploadFileType {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.types
|
||||
}
|
||||
|
||||
// GetDownloadBaseURLs returns the cached list of download base URLs.
|
||||
func GetDownloadBaseURLs() []DownloadBaseURL {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.baseURLs
|
||||
}
|
||||
|
||||
// DefaultDownloadBaseURL returns the base URL used to build attachment download
|
||||
// links: the enabled row marked IsDefault, else the highest-priority enabled
|
||||
// row. Returns an empty string if none is configured.
|
||||
func DefaultDownloadBaseURL() string {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
// Rows are ordered is_default desc, priority asc, so the first enabled row
|
||||
// is the right pick.
|
||||
for _, b := range configCache.baseURLs {
|
||||
if b.Enabled {
|
||||
return b.BaseURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+6
-1
@@ -45,10 +45,15 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
}
|
||||
|
||||
// Auto-migrate tables (idempotent).
|
||||
if err := db.AutoMigrate(&User{}, &Article{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// Seed site platform configuration on first run.
|
||||
seedSiteSettings(db)
|
||||
seedUploadConfig(db)
|
||||
seedUploadFileTypes(db)
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
var count int64
|
||||
db.Model(&User{}).Count(&count)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedSiteSettings inserts the singleton site_settings row (id=1) if absent.
|
||||
// Text fields are left empty so templates fall back to i18n defaults until an
|
||||
// admin configures them.
|
||||
func seedSiteSettings(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&SiteSetting{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
s := &SiteSetting{ID: 1}
|
||||
if err := db.Create(s).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed site_settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedUploadConfig inserts the singleton upload_configs row (id=1) if absent.
|
||||
func seedUploadConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadConfig{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
c := &UploadConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
DefaultMaxSize: DefaultUploadMaxSize,
|
||||
StorageDir: "attachments",
|
||||
}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed upload_configs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
||||
// seeded on first run.
|
||||
var defaultUploadFileTypes = []UploadFileType{
|
||||
// Images
|
||||
{Extension: ".jpg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 1},
|
||||
{Extension: ".jpeg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 2},
|
||||
{Extension: ".png", MimeType: "image/png", Category: CategoryImage, Enabled: true, Sort: 3},
|
||||
{Extension: ".gif", MimeType: "image/gif", Category: CategoryImage, Enabled: true, Sort: 4},
|
||||
{Extension: ".webp", MimeType: "image/webp", Category: CategoryImage, Enabled: true, Sort: 5},
|
||||
// Documents
|
||||
{Extension: ".pdf", MimeType: "application/pdf", Category: CategoryDocument, Enabled: true, Sort: 10},
|
||||
{Extension: ".doc", MimeType: "application/msword", Category: CategoryDocument, Enabled: true, Sort: 11},
|
||||
{Extension: ".docx", MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Category: CategoryDocument, Enabled: true, Sort: 12},
|
||||
{Extension: ".xls", MimeType: "application/vnd.ms-excel", Category: CategoryDocument, Enabled: true, Sort: 13},
|
||||
{Extension: ".xlsx", MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Category: CategoryDocument, Enabled: true, Sort: 14},
|
||||
{Extension: ".ppt", MimeType: "application/vnd.ms-powerpoint", Category: CategoryDocument, Enabled: true, Sort: 15},
|
||||
{Extension: ".pptx", MimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", Category: CategoryDocument, Enabled: true, Sort: 16},
|
||||
{Extension: ".txt", MimeType: "text/plain", Category: CategoryDocument, Enabled: true, Sort: 17},
|
||||
// Archives
|
||||
{Extension: ".zip", MimeType: "application/zip", Category: CategoryArchive, Enabled: true, Sort: 20},
|
||||
{Extension: ".rar", MimeType: "application/vnd.rar", Category: CategoryArchive, Enabled: true, Sort: 21},
|
||||
{Extension: ".7z", MimeType: "application/x-7z-compressed", Category: CategoryArchive, Enabled: true, Sort: 22},
|
||||
// Video
|
||||
{Extension: ".mp4", MimeType: "video/mp4", Category: CategoryVideo, Enabled: true, Sort: 30},
|
||||
{Extension: ".avi", MimeType: "video/x-msvideo", Category: CategoryVideo, Enabled: true, Sort: 31},
|
||||
}
|
||||
|
||||
// seedUploadFileTypes seeds the permitted file-type rows if the table is empty.
|
||||
func seedUploadFileTypes(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadFileType{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
if err := db.Create(&defaultUploadFileTypes).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed upload_file_types: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SiteSetting holds the singleton (id=1) global display configuration:
|
||||
// logo, top-left title, header banner text, and footer text, each with
|
||||
// zh/en variants that fall back to i18n defaults when empty.
|
||||
type SiteSetting struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL
|
||||
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh)
|
||||
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en)
|
||||
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh)
|
||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
|
||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
|
||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (SiteSetting) TableName() string {
|
||||
return "site_settings"
|
||||
}
|
||||
|
||||
// LogoIsURL reports whether the logo value is an external URL rather than a
|
||||
// local filename.
|
||||
func (s *SiteSetting) LogoIsURL() bool {
|
||||
if s == nil || s.Logo == "" {
|
||||
return false
|
||||
}
|
||||
return len(s.Logo) >= 4 && (s.Logo[:4] == "http")
|
||||
}
|
||||
|
||||
// LogoText returns the title for the given language code, falling back to the
|
||||
// other language when the requested one is empty.
|
||||
func (s *SiteSetting) LogoText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.LogoTextZh != "" {
|
||||
return s.LogoTextZh
|
||||
}
|
||||
return s.LogoTextEn
|
||||
}
|
||||
if s.LogoTextEn != "" {
|
||||
return s.LogoTextEn
|
||||
}
|
||||
return s.LogoTextZh
|
||||
}
|
||||
|
||||
// HeaderText returns the header banner text for the given language code,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
func (s *SiteSetting) HeaderText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HeaderTextZh != "" {
|
||||
return s.HeaderTextZh
|
||||
}
|
||||
return s.HeaderTextEn
|
||||
}
|
||||
if s.HeaderTextEn != "" {
|
||||
return s.HeaderTextEn
|
||||
}
|
||||
return s.HeaderTextZh
|
||||
}
|
||||
|
||||
// FooterText returns the footer text for the given language code, falling
|
||||
// back to the other language when the requested one is empty.
|
||||
func (s *SiteSetting) FooterText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.FooterTextZh != "" {
|
||||
return s.FooterTextZh
|
||||
}
|
||||
return s.FooterTextEn
|
||||
}
|
||||
if s.FooterTextEn != "" {
|
||||
return s.FooterTextEn
|
||||
}
|
||||
return s.FooterTextZh
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UploadCategory groups file types for the admin UI.
|
||||
const (
|
||||
CategoryImage = "image"
|
||||
CategoryDocument = "document"
|
||||
CategoryArchive = "archive"
|
||||
CategoryVideo = "video"
|
||||
CategoryOther = "other"
|
||||
)
|
||||
|
||||
// DefaultUploadMaxSize is the default per-file size limit (10 MiB), in bytes.
|
||||
const DefaultUploadMaxSize int64 = 10 * 1024 * 1024
|
||||
|
||||
// UploadConfig holds the singleton (id=1) global attachment upload policy.
|
||||
type UploadConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for attachment uploads
|
||||
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // bytes; overridden per type by UploadFileType.MaxSize
|
||||
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // sub-dir under cfg.Path
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (UploadConfig) TableName() string {
|
||||
return "upload_configs"
|
||||
}
|
||||
|
||||
// UploadFileType describes one permitted attachment extension. Multiple rows.
|
||||
type UploadFileType struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // with leading dot, e.g. ".pdf"
|
||||
MimeType string `gorm:"size:128" json:"mime_type"` // associated MIME for validation
|
||||
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
|
||||
MaxSize int64 `gorm:"default:0" json:"max_size"` // bytes; 0 means use UploadConfig.DefaultMaxSize
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (UploadFileType) TableName() string {
|
||||
return "upload_file_types"
|
||||
}
|
||||
|
||||
// EffectiveMaxSize returns the per-file size limit for this type, falling back
|
||||
// to the provided default when MaxSize is 0.
|
||||
func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
|
||||
if t.MaxSize > 0 {
|
||||
return t.MaxSize
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// DownloadBaseURL is one source base URL used to build attachment download
|
||||
// links. Multiple rows; the row marked IsDefault (or the highest-priority
|
||||
// enabled one) is used for generated links.
|
||||
type DownloadBaseURL struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64" json:"name"` // label, e.g. "主站" / "CDN"
|
||||
BaseURL string `gorm:"size:512" json:"base_url"` // e.g. https://cdn.example.com/uploads
|
||||
Priority int `gorm:"default:0" json:"priority"` // lower = higher priority
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (DownloadBaseURL) TableName() string {
|
||||
return "download_baseurls"
|
||||
}
|
||||
Reference in New Issue
Block a user