feat: add article comment system with moderation

- Comment model with nested replies (ParentID), dual authorship
  (logged-in UserID / anonymous GuestToken cookie), email hash for
  Gravatar, private flag, and moderation status
- CommentConfig singleton (enabled / allow guest / guest-require-approval
  / use Gravatar) cached like the other platform config
- Markdown comments with built-in emoji picker, preview, and markdown
  help; rendered client-side via marked + DOMPurify, with server-side
  HTML/dangerous-scheme stripping as a first XSS defense
- Private comments visible only to admin and the author; pending
  comments visible only to admin and the author
- Admin moderation list (pending/approved/rejected/all tabs) with
  approve/reject/delete and a pending-count badge
- Comment settings page with the four toggles
- One-time session flash notice (auto-dismissed after 4s) so the
  "comment posted" banner no longer persists across refreshes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:21:54 +08:00
co-authored by Claude Fable 5
parent 1f1123acf8
commit 6139b3ef2c
18 changed files with 1351 additions and 18 deletions
+106
View File
@@ -0,0 +1,106 @@
package models
import (
"crypto/md5"
"encoding/hex"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// Comment status constants.
const (
CommentPending = 0 // 待审核
CommentApproved = 1 // 已通过
CommentRejected = 2 // 已拒绝(软拒绝;后台仍可查看,但前台不再显示)
)
// Comment represents one reader-submitted comment on an article. Comments may
// be nested via ParentID and authored by either a logged-in user (UserID) or
// an anonymous visitor identified by a long-lived GuestToken cookie.
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ArticleID uint `gorm:"not null;index" json:"article_id"`
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
// Authorship: logged-in users get UserID; anonymous visitors get a random
// GuestToken stored in a cookie so they can see their own pending/private
// comments on subsequent page loads.
UserID *uint `gorm:"index" json:"user_id,omitempty"`
GuestToken string `gorm:"size:64;index" json:"-"`
AuthorName string `gorm:"not null;size:64" json:"author_name"`
Email string `gorm:"not null;size:255" json:"-"`
EmailHash string `gorm:"size:64;index" json:"email_hash"`
Website string `gorm:"size:255" json:"website,omitempty"`
Content string `gorm:"type:text;not null" json:"content"`
IsPrivate bool `gorm:"default:false;index" json:"is_private"`
Status int `gorm:"default:0;index" json:"status"`
IPAddress string `gorm:"size:64" json:"-"`
UserAgent string `gorm:"size:512" json:"-"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
}
// TableName overrides the default GORM table name.
func (Comment) TableName() string {
return "comments"
}
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
// is the form expected by Gravatar.
func HashEmail(email string) string {
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
return hex.EncodeToString(sum[:])
}
// GravatarURL returns a Gravatar avatar URL for this comment's email hash.
// Falls back to the "identicon" default avatar when no Gravatar exists.
func (c *Comment) GravatarURL(size int) string {
if size <= 0 {
size = 64
}
hash := c.EmailHash
if hash == "" {
hash = HashEmail(c.Email)
}
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?s=%d&d=identicon", hash, size)
}
// MaskedEmail returns the email with the local part partially obscured, for
// admin-side listings that should hint at identity without exposing the
// full address.
func (c *Comment) MaskedEmail() string {
email := c.Email
at := strings.LastIndex(email, "@")
if at <= 0 {
return email
}
local := email[:at]
host := email[at:]
if len(local) <= 2 {
return string(local[0]) + "***" + host
}
return string(local[0]) + "***" + string(local[len(local)-1]) + host
}
// AuthorInitial returns an uppercase first character of AuthorName for use as
// a text-based avatar placeholder when Gravatar is disabled. Returns "?" when
// the name is empty.
func (c *Comment) AuthorInitial() string {
name := strings.TrimSpace(c.AuthorName)
if name == "" {
return "?"
}
r := []rune(name)
return strings.ToUpper(string(r[0]))
}
+31
View File
@@ -0,0 +1,31 @@
package models
import "time"
// CommentConfig holds the singleton (id=1) global comment policy.
type CommentConfig struct {
ID uint `gorm:"primarykey" json:"id"`
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue
UseGravatar bool `gorm:"default:true" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder
UpdatedBy uint `gorm:"index" json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
func (CommentConfig) TableName() string {
return "comment_configs"
}
// defaultCommentConfig returns the in-memory fallback used before the DB row is
// seeded, matching the seed defaults.
func defaultCommentConfig() *CommentConfig {
return &CommentConfig{
ID: 1,
Enabled: true,
AllowGuest: true,
GuestRequireApproval: false,
UseGravatar: true,
}
}
+15
View File
@@ -14,6 +14,7 @@ var configCache = struct {
mu sync.RWMutex
site *SiteSetting
upload *UploadConfig
comment *CommentConfig
types []UploadFileType
baseURLs []DownloadBaseURL
}{
@@ -41,6 +42,13 @@ func LoadConfigCache(db *gorm.DB) {
configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}
}
var cc CommentConfig
if err := db.First(&cc, 1).Error; err == nil {
configCache.comment = &cc
} else {
configCache.comment = defaultCommentConfig()
}
var t []UploadFileType
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
configCache.types = t
@@ -73,6 +81,13 @@ func GetUploadConfig() *UploadConfig {
return configCache.upload
}
// GetCommentConfig returns the cached comment policy.
func GetCommentConfig() *CommentConfig {
configCache.mu.RLock()
defer configCache.mu.RUnlock()
return configCache.comment
}
// GetUploadFileTypes returns the cached list of permitted file types.
func GetUploadFileTypes() []UploadFileType {
configCache.mu.RLock()
+2 -1
View File
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
}
// Auto-migrate tables (idempotent).
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}); err != nil {
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}); err != nil {
log.Fatalf("Failed to auto-migrate database: %v", err)
}
@@ -53,6 +53,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
seedSiteSettings(db)
seedUploadConfig(db)
seedUploadFileTypes(db)
seedCommentConfig(db)
// First-run seed: create admin user if no users exist.
var count int64
+19
View File
@@ -39,6 +39,25 @@ func seedUploadConfig(db *gorm.DB) {
}
}
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
func seedCommentConfig(db *gorm.DB) {
var count int64
db.Model(&CommentConfig{}).Count(&count)
if count > 0 {
return
}
c := &CommentConfig{
ID: 1,
Enabled: true,
AllowGuest: true,
GuestRequireApproval: false,
UseGravatar: true,
}
if err := db.Create(c).Error; err != nil {
log.Printf("Warning: failed to seed comment_configs: %v", err)
}
}
// defaultUploadFileTypes is the set of commonly permitted attachment types
// seeded on first run.
var defaultUploadFileTypes = []UploadFileType{