Files
go_blog/models/comment.go
T
kevinandClaude Fable 5 6139b3ef2c 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>
2026-06-22 17:21:54 +08:00

107 lines
3.3 KiB
Go

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]))
}