docs: 全部 Go 代码注释汉化

- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
This commit is contained in:
2026-08-27 19:03:03 +08:00
parent fed6bf9570
commit f307781f58
48 files changed
+983 -1080

No files matched your search

+19 -19
View File
@@ -6,34 +6,34 @@ import (
"gorm.io/gorm"
)
// Article status constants.
// 文章状态常量。
const (
ArticleDraft = 0 // 草稿
ArticlePublished = 1 // 已发布
ArticleArchived = 2 // 已归档
)
// Article represents a blog post.
// Article 表示一篇博客文章。
type Article struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"`
AuthorID uint `gorm:"not null;index" json:"author_id"`
Title string `gorm:"not null;size:255" json:"title"`
Summary string `gorm:"size:512" json:"summary"`
Content string `gorm:"type:text;not null" json:"content"`
Cover string `gorm:"size:512" json:"cover"`
Status int `gorm:"default:0;index" json:"status"`
IsTop bool `gorm:"default:false" json:"is_top"`
ViewCount int `gorm:"default:0" json:"view_count"`
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
PublishedAt *time.Time `gorm:"index" json:"published_at"`
Author User `gorm:"foreignKey:AuthorID" json:"-"`
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"`
AuthorID uint `gorm:"not null;index" json:"author_id"`
Title string `gorm:"not null;size:255" json:"title"`
Summary string `gorm:"size:512" json:"summary"`
Content string `gorm:"type:text;not null" json:"content"`
Cover string `gorm:"size:512" json:"cover"`
Status int `gorm:"default:0;index" json:"status"`
IsTop bool `gorm:"default:false" json:"is_top"`
ViewCount int `gorm:"default:0" json:"view_count"`
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
PublishedAt *time.Time `gorm:"index" json:"published_at"`
Author User `gorm:"foreignKey:AuthorID" json:"-"`
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (Article) TableName() string {
return "articles"
}
+2 -2
View File
@@ -4,14 +4,14 @@ import (
"time"
)
// ArticleTag represents the many-to-many relationship between articles and tags.
// ArticleTag 表示文章与标签之间的多对多关联。
type ArticleTag struct {
ArticleID uint `gorm:"primaryKey;index" json:"article_id"`
TagID uint `gorm:"primaryKey;index" json:"tag_id"`
CreatedAt time.Time `json:"created_at"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (ArticleTag) TableName() string {
return "article_tags"
}
+7 -7
View File
@@ -6,13 +6,13 @@ import (
"gorm.io/gorm"
)
// ArticleView represents a unique article view record.
// Each record tracks one unique visit (by IP or user) to an article.
// ArticleView 表示一条唯一的文章浏览记录。
// 每条记录跟踪一次(按 IP 或用户)对文章的唯一访问。
type ArticleView struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
ArticleID uint `gorm:"not null;index:idx_article_views_article" json:"article_id"`
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // NULL for anonymous users
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // 匿名用户为 NULL
IP string `gorm:"size:64;not null;index:idx_article_views_ip" json:"ip"`
UserAgent string `gorm:"size:512" json:"user_agent"`
IsBot bool `gorm:"default:false;index:idx_article_views_bot" json:"is_bot"`
@@ -20,13 +20,13 @@ type ArticleView struct {
User *User `gorm:"foreignKey:UserID" json:"-"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (ArticleView) TableName() string {
return "article_views"
}
// BeforeCreate hook to ensure we don't create duplicate records.
// This is a safety check in addition to application-level deduplication.
// BeforeCreate 钩子确保不会创建重复记录。
// 这是在应用层去重之外的另一道安全校验。
func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
var count int64
query := tx.Model(&ArticleView{}).
@@ -40,7 +40,7 @@ func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
query.Count(&count)
if count > 0 {
// Record already exists, skip creation
// 记录已存在,跳过创建
return gorm.ErrDuplicatedKey
}
+16 -19
View File
@@ -6,27 +6,25 @@ import (
"gorm.io/gorm"
)
// Attachment represents a file attached to an article.
// Attachment 表示附加到文章的文件。
//
// Lifecycle (plan A — upload-then-bind):
// - On the article-create page the article does not exist yet, so ArticleID
// is 0 and the row is temporarily owned by SessionToken (a random value
// generated for the page session).
// - When the article is saved, ArticleCreate binds pending rows by
// SessionToken, setting their ArticleID and clearing the token.
// - On the edit page uploads carry the real ArticleID directly.
// 生命周期(方案 A——先上传后绑定):
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0
// 该行暂时由 SessionToken(为页面会话生成的随机值)持有。
// - 保存文章时,ArticleCreate 通过 SessionToken 绑定待处理行,
// 设置它们的 ArticleID 并清除令牌。
// - 在编辑页面上,上传直接携带真实的 ArticleID。
//
// Disk deduplication: StoredName is the SHA-256 of the file content. Before
// writing, the handler checks whether a file with that name already exists on
// disk; if so it is reused (no rewrite). Deletion uses reference counting —
// the disk file is removed only when no Attachment rows reference it.
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
// 删除采用引用计数——仅当没有任何 Attachment 行引用时,才删除磁盘文件。
type Attachment struct {
ID uint `gorm:"primarykey" json:"id"`
ArticleID uint `gorm:"index" json:"article_id"` // 0 while pending on the create page
SessionToken string `gorm:"size:64;index" json:"-"` // temporary ownership token for the create page
ArticleID uint `gorm:"index" json:"article_id"` // 在创建页面上待绑定时为 0
SessionToken string `gorm:"size:64;index" json:"-"` // 创建页面上的临时归属令牌
UploaderID uint `gorm:"index" json:"uploader_id"`
Filename string `gorm:"size:255" json:"filename"` // original filename
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 hex, the on-disk filename
Filename string `gorm:"size:255" json:"filename"` // 原始文件名
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 十六进制字符串,磁盘文件名
Ext string `gorm:"size:32" json:"ext"`
MIME string `gorm:"size:128" json:"mime"`
Size int64 `gorm:"default:0" json:"size"`
@@ -36,13 +34,12 @@ type Attachment struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (Attachment) TableName() string {
return "attachments"
}
// AttachmentCategoryImage reports whether this attachment is an image (used to
// decide markdown insertion form: ![]() vs []()).
// IsImage 报告该附件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
func (a *Attachment) IsImage() bool {
return a.Category == CategoryImage
}
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"strings"
)
// botPatterns contains common bot/crawler/spider User-Agent patterns.
// botPatterns 包含常见的 bot/爬虫/蜘蛛 User-Agent 特征模式。
var botPatterns = []string{
"bot", "crawler", "spider", "scraper", "scraping",
"googlebot", "bingbot", "baiduspider", "yandexbot",
@@ -19,8 +19,8 @@ var botPatterns = []string{
"headless", "phantom", "selenium", "puppeteer",
}
// IsBot checks if the given User-Agent string matches known bot patterns.
// It performs a case-insensitive substring match against common bot identifiers.
// IsBot 检查给定的 User-Agent 字符串是否匹配已知的 bot 特征模式。
// 它针对常见的 bot 标识进行不区分大小写的子串匹配。
func IsBot(userAgent string) bool {
if userAgent == "" {
return false
+16 -18
View File
@@ -10,16 +10,16 @@ import (
"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.
// Comment 表示读者对文章提交的一条评论。评论可通过 ParentID 进行嵌套,
// 作者可以是已登录用户(UserID),也可以通过长期有效的 GuestToken Cookie
// 标识的匿名访客。
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
@@ -29,9 +29,9 @@ type Comment struct {
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;匿名访客使用随机的
// GuestToken(保存于 Cookie 中),以便后续页面加载时能查看
// 自己待审核/私密的评论。
UserID *uint `gorm:"index" json:"user_id,omitempty"`
GuestToken string `gorm:"size:64;index" json:"-"`
@@ -51,20 +51,20 @@ type Comment struct {
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (Comment) TableName() string {
return "comments"
}
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
// is the form expected by Gravatar.
// HashEmail 返回小写并去除空格后的邮箱地址的 md5 哈希值。
// 这是 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.
// GravatarURL 根据此评论的邮箱哈希返回 Gravatar 头像 URL。
// 当没有对应的 Gravatar 头像时,回退到 "identicon" 默认头像。
func (c *Comment) GravatarURL(size int) string {
if size <= 0 {
size = 64
@@ -76,9 +76,8 @@ func (c *Comment) GravatarURL(size int) string {
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.
// MaskedEmail 返回把本地部分部分遮盖后的邮箱,供后台列表展示:
// 既能提示身份,又不暴露完整地址。
func (c *Comment) MaskedEmail() string {
email := c.Email
at := strings.LastIndex(email, "@")
@@ -93,9 +92,8 @@ func (c *Comment) MaskedEmail() string {
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.
// AuthorInitial 返回 AuthorName 的首个大写字符,用作禁用 Gravatar 时
// 的文本头像占位符。名称为空时返回 "?"。
func (c *Comment) AuthorInitial() string {
name := strings.TrimSpace(c.AuthorName)
if name == "" {
+12 -12
View File
@@ -2,26 +2,26 @@ package models
import "time"
// CommentConfig holds the singleton (id=1) global comment policy.
// CommentConfig 保存单例(id=1)的全局评论策略。
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
// SECURITY_TODO #15: Gravatar reveals MD5(email) via reverse lookup;
// off by default on new deployments (admins can re-enable explicitly).
UseGravatar bool `gorm:"default:false" 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"`
Enabled bool `gorm:"default:true" json:"enabled"` // 评论系统的总开关
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // 是否允许匿名(非登录)评论
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // 将访客评论置于审核队列
// SECURITY_TODO #15Gravatar 可通过反向查询暴露 MD5(邮箱)
// 新部署默认关闭(管理员可显式重新启用)。
UseGravatar bool `gorm:"default:false" json:"use_gravatar"` // false 时,头像显示为文本首字母占位
UpdatedBy uint `gorm:"index" json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (CommentConfig) TableName() string {
return "comment_configs"
}
// defaultCommentConfig returns the in-memory fallback used before the DB row is
// seeded, matching the seed defaults.
// defaultCommentConfig 返回数据库行被初始化之前使用的内存回退值,
// 与初始化种子默认值保持一致。
func defaultCommentConfig() *CommentConfig {
return &CommentConfig{
ID: 1,
+17 -20
View File
@@ -6,10 +6,9 @@ import (
"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).
// configCache 保存平台配置的进程级缓存,使每次请求的渲染与上传校验
// 都不必访问数据库。缓存启动时填充一次,每当管理员保存设置页面时刷新
// (参见 RefreshConfigCache / 各管理处理器)。
var configCache = struct {
mu sync.RWMutex
site *SiteSetting
@@ -23,8 +22,8 @@ var configCache = struct {
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.
// LoadConfigCache 将所有平台配置从数据库读入进程缓存。
// 在 InitDB 之后于启动时调用一次。
func LoadConfigCache(db *gorm.DB) {
configCache.mu.Lock()
defer configCache.mu.Unlock()
@@ -66,57 +65,55 @@ func LoadConfigCache(db *gorm.DB) {
}
}
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
// call this after writing changes so the next request sees them.
// RefreshConfigCache 重新加载所有缓存中的平台配置。管理处理器在写入变更后
// 调用此方法,以便下一次请求能看到更新。
func RefreshConfigCache(db *gorm.DB) {
LoadConfigCache(db)
}
// GetSiteSetting returns a pointer to the cached site settings (read-only copy
// semantics: callers must not mutate).
// GetSiteSetting 返回缓存的站点设置指针(只读副本语义:调用方不得修改)。
func GetSiteSetting() *SiteSetting {
configCache.mu.RLock()
defer configCache.mu.RUnlock()
return configCache.site
}
// GetUploadConfig returns the cached upload policy.
// GetUploadConfig 返回缓存的上传策略。
func GetUploadConfig() *UploadConfig {
configCache.mu.RLock()
defer configCache.mu.RUnlock()
return configCache.upload
}
// GetCommentConfig returns the cached comment policy.
// GetCommentConfig 返回缓存的评论策略。
func GetCommentConfig() *CommentConfig {
configCache.mu.RLock()
defer configCache.mu.RUnlock()
return configCache.comment
}
// GetUploadFileTypes returns the cached list of permitted file types.
// GetUploadFileTypes 返回缓存的允许文件类型列表。
func GetUploadFileTypes() []UploadFileType {
configCache.mu.RLock()
defer configCache.mu.RUnlock()
return configCache.types
}
// GetDownloadBaseURLs returns the cached list of download base URLs.
// GetDownloadBaseURLs 返回缓存的下载基础 URL 列表。
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.
// DefaultDownloadBaseURL 返回用于构建附件下载链接的基础 URL:
// 首选标记为 IsDefault 且启用的行,否则选择优先级最高的启用行。
// 若未配置任何项则返回空字符串。
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.
// 行按 is_default descpriority asc 排序,因此第一个启用行即为正确选择。
for _, b := range configCache.baseURLs {
if b.Enabled {
return b.BaseURL
@@ -125,7 +122,7 @@ func DefaultDownloadBaseURL() string {
return ""
}
// GetNavLinks returns the cached list of enabled navigation links, sorted by sort order.
// GetNavLinks 返回缓存的启用导航链接列表,按排序顺序排列。
func GetNavLinks() []NavLink {
configCache.mu.RLock()
defer configCache.mu.RUnlock()
+13 -13
View File
@@ -15,15 +15,15 @@ import (
"github.com/glebarez/sqlite"
)
// DB is the global database connection, initialized by InitDB.
// DB 是全局数据库连接,由 InitDB 初始化。
var DB *gorm.DB
// adminPasswordAlphabet avoids visually ambiguous characters (no l, I, O, 0,
// 1) and is used to generate the first-run admin password.
// adminPasswordAlphabet 避免了视觉上易混淆的字符(不含 l、I、O、0、1),
// 用于生成首次运行的管理员密码。
const adminPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" + "^$*+?%"
// randomAdminPassword returns a crypto-random 16-character first-run admin
// password (SECURITY_TODO #12: no more hardcoded admin/admin).
// randomAdminPassword 返回密码学随机的 16 位首次运行管理员密码
// SECURITY_TODO #12:不再硬编码 admin/admin)。
func randomAdminPassword() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
@@ -36,9 +36,9 @@ func randomAdminPassword() string {
return string(out)
}
// InitDB opens the database connection, runs migrations, and seeds the admin user.
// InitDB 打开数据库连接、执行迁移并初始化管理员用户。
func InitDB(cfg *config.Config) *gorm.DB {
// Ensure the storage path exists.
// 确保存储目录存在。
if err := os.MkdirAll(cfg.Path, 0755); err != nil {
log.Fatalf("Failed to create storage directory %s: %v", cfg.Path, err)
}
@@ -63,18 +63,18 @@ func InitDB(cfg *config.Config) *gorm.DB {
log.Fatalf("Failed to connect to database: %v", err)
}
// Auto-migrate tables (idempotent).
// 自动迁移数据表(幂等操作)。
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
log.Fatalf("Failed to auto-migrate database: %v", err)
}
// Seed site platform configuration on first run.
// 首次运行时初始化站点平台配置。
seedSiteSettings(db)
seedUploadConfig(db)
seedUploadFileTypes(db)
seedCommentConfig(db)
// First-run seed: create admin user if no users exist.
// 首次运行初始化:若不存在任何用户则创建管理员用户。
var count int64
db.Model(&User{}).Count(&count)
if count == 0 {
@@ -92,8 +92,8 @@ func InitDB(cfg *config.Config) *gorm.DB {
if err := db.Create(admin).Error; err != nil {
log.Fatalf("Failed to create admin user: %v", err)
}
// SECURITY_TODO #12: the first-run password is crypto-random and
// printed exactly once — copy it now; it cannot be recovered later.
// SECURITY_TODO #12:首次运行密码为密码学随机生成,且只打印一次——
// 请立即抄写;之后将无法找回。
log.Println("==============================================")
log.Println(" First run: created default admin user.")
log.Println(" Username: admin")
@@ -102,7 +102,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
log.Println("==============================================")
}
// Migration fix: always set admin role on the admin user.
// 迁移修复:始终为 admin 用户设置管理员角色。
result := db.Model(&User{}).Where("username = ?", "admin").Update("role", RoleAdmin)
if result.RowsAffected > 0 {
log.Printf("Migration: set admin role for existing admin user (rows affected: %d)", result.RowsAffected)
+4 -5
View File
@@ -5,9 +5,8 @@ import (
"testing"
)
// TestRandomAdminPassword covers SECURITY_TODO #12: the first-run admin
// password comes from the ambiguous-safe alphabet, has fixed length, and
// differs between generations.
// TestRandomAdminPassword 覆盖 SECURITY_TODO #12:首次运行的管理员密码
// 来自无歧义安全的字符表、长度固定,且每次生成结果不同。
func TestRandomAdminPassword(t *testing.T) {
pw := randomAdminPassword()
if len(pw) != 16 {
@@ -23,8 +22,8 @@ func TestRandomAdminPassword(t *testing.T) {
}
}
// TestGravatarOffByDefault covers SECURITY_TODO #15: new deployments must not
// leak MD5(email) to Gravatar unless an admin deliberately enables it.
// TestGravatarOffByDefault 覆盖 SECURITY_TODO #15:新部署不得将 MD5(邮箱)
// 泄露给 Gravatar,除非管理员明确启用。
func TestGravatarOffByDefault(t *testing.T) {
cc := defaultCommentConfig()
if cc.UseGravatar {
+9 -10
View File
@@ -2,27 +2,26 @@ package models
import "time"
// NavLink represents a custom navigation link in the header.
// NavLink 表示页头中的自定义导航链接。
type NavLink struct {
ID uint `gorm:"primarykey" json:"id"`
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // Link text (Chinese)
TitleEn string `gorm:"size:100;not null" json:"title_en"` // Link text (English)
URL string `gorm:"size:512;not null" json:"url"` // Target URL
OpenNew bool `gorm:"default:false" json:"open_new"` // Open in new window
Enabled bool `gorm:"default:true" json:"enabled"` // Show/hide link
Sort int `gorm:"default:0;index" json:"sort"` // Display order (lower = first)
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // 链接文本(中文)
TitleEn string `gorm:"size:100;not null" json:"title_en"` // 链接文本(英文)
URL string `gorm:"size:512;not null" json:"url"` // 目标 URL
OpenNew bool `gorm:"default:false" json:"open_new"` // 在新窗口中打开
Enabled bool `gorm:"default:true" json:"enabled"` // 显示/隐藏链接
Sort int `gorm:"default:0;index" json:"sort"` // 显示顺序(数值越小越靠前)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy uint `gorm:"index" json:"updated_by"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (NavLink) TableName() string {
return "nav_links"
}
// Title returns the link text for the given language code, falling back to
// the other language when the requested one is empty.
// Title 返回指定语言代码下的链接文本,当所请求语言为空时回退到另一语言。
func (n *NavLink) Title(lang string) string {
if lang == "zh" {
if n.TitleZh != "" {
+12 -15
View File
@@ -6,9 +6,8 @@ import (
"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.
// seedSiteSettings 在不存在时插入单例的 site_settings 行(id=1)。
// 文本字段留空,以便模板回退到 i18n 默认值,直到管理员配置它们为止。
func seedSiteSettings(db *gorm.DB) {
var count int64
db.Model(&SiteSetting{}).Count(&count)
@@ -21,7 +20,7 @@ func seedSiteSettings(db *gorm.DB) {
}
}
// seedUploadConfig inserts the singleton upload_configs row (id=1) if absent.
// seedUploadConfig 在不存在时插入单例的 upload_configs 行(id=1)。
func seedUploadConfig(db *gorm.DB) {
var count int64
db.Model(&UploadConfig{}).Count(&count)
@@ -39,7 +38,7 @@ func seedUploadConfig(db *gorm.DB) {
}
}
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
// seedCommentConfig 在不存在时插入单例的 comment_configs 行(id=1)。
func seedCommentConfig(db *gorm.DB) {
var count int64
db.Model(&CommentConfig{}).Count(&count)
@@ -51,9 +50,8 @@ func seedCommentConfig(db *gorm.DB) {
Enabled: true,
AllowGuest: true,
GuestRequireApproval: false,
// SECURITY_TODO #15: Gravatar reveals MD5(email) via reverse lookup;
// disabled by default on new deployments. Admins can re-enable from
// the comment settings page.
// SECURITY_TODO #15Gravatar 可通过反向查询暴露 MD5(邮箱)
// 新部署默认关闭,管理员可在评论设置页面重新启用。
UseGravatar: false,
}
if err := db.Create(c).Error; err != nil {
@@ -61,16 +59,15 @@ func seedCommentConfig(db *gorm.DB) {
}
}
// defaultUploadFileTypes is the set of commonly permitted attachment types
// seeded on first run.
// defaultUploadFileTypes 是首次运行初始化的常用允许附件类型集合。
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},
@@ -79,16 +76,16 @@ var defaultUploadFileTypes = []UploadFileType{
{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.
// seedUploadFileTypes 在表为空时初始化允许的文件类型行。
func seedUploadFileTypes(db *gorm.DB) {
var count int64
db.Model(&UploadFileType{}).Count(&count)
+34 -37
View File
@@ -2,38 +2,37 @@ 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.
// SiteSetting 保存单例(id=1)的全局展示配置:
// 徽标、左上角标题、页头横幅文案和页脚文案,每项均有 zh/en 变体,
// 为空时回退到 i18n 默认值。
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
Favicon string `gorm:"size:512" json:"favicon"` // 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)
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (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)
// SiteURL is the canonical site base URL used for RSS/feed links
// (SECURITY_TODO #16); empty falls back to the request Host at runtime.
SiteURL string `gorm:"size:512" json:"site_url"`
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register
UpdatedBy uint `gorm:"index" json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primarykey" json:"id"`
Logo string `gorm:"size:512" json:"logo"` // 本地文件名(供 /uploads/logos 目录提供)或完整 URL
Favicon string `gorm:"size:512" json:"favicon"` // 本地文件名(供 /uploads/logos 目录提供)或完整 URL
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // 左上角标题(中文)
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // 左上角标题(英文)
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // 页头横幅文案(中文)
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // 页头横幅文案(英文)
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // 首页欢迎标题(中文)
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // 首页欢迎标题(英文)
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"`// 首页副标题(中文)
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"`// 首页副标题(英文)
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // 页脚文案(中文)
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // 页脚文案(英文)
// SiteURL 是用于 RSS/订阅链接的规范化站点基础 URLSECURITY_TODO #16);
// 为空时在运行时回退到请求的 Host。
SiteURL string `gorm:"size:512" json:"site_url"`
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // 是否允许用户自助注册
UpdatedBy uint `gorm:"index" json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (SiteSetting) TableName() string {
return "site_settings"
}
// LogoIsURL reports whether the logo value is an external URL rather than a
// local filename.
// LogoIsURL 报告徽标值是否为外部 URL 而非本地文件名。
func (s *SiteSetting) LogoIsURL() bool {
if s == nil || s.Logo == "" {
return false
@@ -41,8 +40,7 @@ func (s *SiteSetting) LogoIsURL() bool {
return len(s.Logo) >= 4 && (s.Logo[:4] == "http")
}
// FaviconIsURL reports whether the favicon value is an external URL rather than a
// local filename.
// FaviconIsURL 报告 favicon 值是否为外部 URL 而非本地文件名。
func (s *SiteSetting) FaviconIsURL() bool {
if s == nil || s.Favicon == "" {
return false
@@ -50,8 +48,7 @@ func (s *SiteSetting) FaviconIsURL() bool {
return len(s.Favicon) >= 4 && (s.Favicon[:4] == "http")
}
// LogoText returns the title for the given language code, falling back to the
// other language when the requested one is empty.
// LogoText 返回指定语言代码下的标题,当所请求语言为空时回退到另一语言。
func (s *SiteSetting) LogoText(lang string) string {
if lang == "zh" {
if s.LogoTextZh != "" {
@@ -65,8 +62,8 @@ func (s *SiteSetting) LogoText(lang string) string {
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.
// HeaderText 返回指定语言代码下的页头横幅文案,当所请求语言为空时
// 回退到另一语言。
func (s *SiteSetting) HeaderText(lang string) string {
if lang == "zh" {
if s.HeaderTextZh != "" {
@@ -80,8 +77,8 @@ func (s *SiteSetting) HeaderText(lang string) string {
return s.HeaderTextZh
}
// HomeWelcome returns the home page welcome heading for the given language,
// falling back to the other language when the requested one is empty.
// HomeWelcome 返回指定语言下的首页欢迎标题,当所请求语言为空时
// 回退到另一语言。
func (s *SiteSetting) HomeWelcome(lang string) string {
if lang == "zh" {
if s.HomeWelcomeZh != "" {
@@ -95,8 +92,8 @@ func (s *SiteSetting) HomeWelcome(lang string) string {
return s.HomeWelcomeZh
}
// HomeSubtitle returns the home page subtitle for the given language,
// falling back to the other language when the requested one is empty.
// HomeSubtitle 返回指定语言下的首页副标题,当所请求语言为空时
// 回退到另一语言。
func (s *SiteSetting) HomeSubtitle(lang string) string {
if lang == "zh" {
if s.HomeSubtitleZh != "" {
@@ -110,8 +107,8 @@ func (s *SiteSetting) HomeSubtitle(lang string) string {
return s.HomeSubtitleZh
}
// FooterText returns the footer text for the given language code, falling
// back to the other language when the requested one is empty.
// FooterText 返回指定语言代码下的页脚文案,当所请求语言为空时
// 回退到另一语言。
func (s *SiteSetting) FooterText(lang string) string {
if lang == "zh" {
if s.FooterTextZh != "" {
+14 -14
View File
@@ -7,7 +7,7 @@ import (
"gorm.io/gorm"
)
// Tag represents a blog post tag with multi-language support.
// Tag 表示支持多语言的博客文章标签。
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
NameZh string `gorm:"size:50;not null" json:"name_zh"`
@@ -18,12 +18,12 @@ type Tag struct {
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (Tag) TableName() string {
return "tags"
}
// Name returns the tag name for the specified language.
// Name 返回指定语言下的标签名称。
func (t *Tag) Name(lang string) string {
if lang == "zh" {
return t.NameZh
@@ -31,7 +31,7 @@ func (t *Tag) Name(lang string) string {
return t.NameEn
}
// generateTagSlug creates a URL-friendly slug from tag name.
// generateTagSlug 根据标签名称生成对 URL 友好的 slug。
func generateTagSlug(name string) string {
slug := strings.ToLower(strings.TrimSpace(name))
slug = strings.ReplaceAll(slug, " ", "-")
@@ -39,13 +39,13 @@ func generateTagSlug(name string) string {
return slug
}
// FindOrCreateTag finds a tag by name or creates it if it doesn't exist.
// If both nameZh and nameEn are provided, it uses them; otherwise uses the same name for both languages.
// FindOrCreateTag 按名称查找标签,不存在则创建。
// 若同时提供 nameZh nameEn 则分别使用;否则两种语言使用相同的名称。
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
nameZh = strings.TrimSpace(nameZh)
nameEn = strings.TrimSpace(nameEn)
// If only one name is provided, use it for both languages
// 若只提供其中一个名称,两种语言都使用它
if nameZh == "" && nameEn != "" {
nameZh = nameEn
} else if nameEn == "" && nameZh != "" {
@@ -68,7 +68,7 @@ func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
return nil, err
}
// Create new tag
// 创建新标签
tag = Tag{
NameZh: nameZh,
NameEn: nameEn,
@@ -83,14 +83,14 @@ func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
return &tag, nil
}
// GetAllTags returns all tags ordered by count descending.
// GetAllTags 按数量降序返回所有标签。
func GetAllTags(db *gorm.DB) ([]Tag, error) {
var tags []Tag
err := db.Order("count DESC, name_zh ASC").Find(&tags).Error
return tags, err
}
// GetTagBySlug returns a tag by its slug.
// GetTagBySlug 根据 slug 返回标签。
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
var tag Tag
err := db.Where("slug = ?", slug).First(&tag).Error
@@ -100,22 +100,22 @@ func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
return &tag, nil
}
// UpdateTagCount recalculates the article count for a tag.
// UpdateTagCount 重新计算某个标签的文章数量。
func UpdateTagCount(db *gorm.DB, tagID uint) error {
var count int64
db.Table("article_tags").Where("tag_id = ?", tagID).Count(&count)
return db.Model(&Tag{}).Where("id = ?", tagID).Update("count", count).Error
}
// UpdateAllTagCounts recalculates article counts for all tags.
// UpdateAllTagCounts 重新计算所有标签的文章数量。
func UpdateAllTagCounts(db *gorm.DB) error {
// Get all tags
// 获取所有标签
var tags []Tag
if err := db.Find(&tags).Error; err != nil {
return err
}
// Update count for each tag
// 为每个标签更新数量
for _, tag := range tags {
if err := UpdateTagCount(db, tag.ID); err != nil {
return err
+25 -25
View File
@@ -2,7 +2,7 @@ package models
import "time"
// UploadCategory groups file types for the admin UI.
// UploadCategory 为管理界面按类别归类文件类型。
const (
CategoryImage = "image"
CategoryDocument = "document"
@@ -11,42 +11,42 @@ const (
CategoryOther = "other"
)
// DefaultUploadMaxSize is the default per-file size limit (10 MiB), in bytes.
// DefaultUploadMaxSize 是默认的单文件大小上限(10 MiB),单位为字节。
const DefaultUploadMaxSize int64 = 10 * 1024 * 1024
// UploadConfig holds the singleton (id=1) global attachment upload policy.
// UploadConfig 保存单例(id=1)的全局附件上传策略。
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"`
ID uint `gorm:"primarykey" json:"id"`
Enabled bool `gorm:"default:true" json:"enabled"` // 附件上传总开关
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // 字节;可被 UploadFileType.MaxSize 按类型覆盖
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // cfg.Path 下的子目录
UpdatedBy uint `gorm:"index" json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
func (UploadConfig) TableName() string {
return "upload_configs"
}
// UploadFileType describes one permitted attachment extension. Multiple rows.
// UploadFileType 描述一种允许的附件扩展名。允许多行记录。
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
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // 带前导点,如 ".pdf"
MimeType string `gorm:"size:128" json:"mime_type"` // 用于校验的关联 MIME
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
MaxSize int64 `gorm:"default:0" json:"max_size"` // 字节;0 表示使用 UploadConfig.DefaultMaxSize
Enabled bool `gorm:"default:true" json:"enabled"`
Sort int `gorm:"default:0" json:"sort"`
}
// TableName overrides the default GORM table name.
// TableName 覆盖 GORM 默认的表名。
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.
// EffectiveMaxSize 返回该类型下单个文件的大小上限,当 MaxSize 为 0 时
// 回退到传入的默认值。
func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
if t.MaxSize > 0 {
return t.MaxSize
@@ -54,21 +54,21 @@ func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
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.
// DownloadBaseURL 是一种用于构建附件下载链接的来源基础 URL。
// 允许多行记录;标记为 IsDefault(或优先级最高且启用)的行
// 用于生成链接。
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
Name string `gorm:"size:64" json:"name"` // 标签,如 "主站" / "CDN"
BaseURL string `gorm:"size:512" json:"base_url"` // 例如 https://cdn.example.com/uploads
Priority int `gorm:"default:0" json:"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.
// TableName 覆盖 GORM 默认的表名。
func (DownloadBaseURL) TableName() string {
return "download_baseurls"
}
+8 -9
View File
@@ -7,7 +7,7 @@ import (
"gorm.io/gorm"
)
// Account status constants.
// 账号状态常量。
const (
StatusDisabled = 0 // 禁用
StatusNormal = 1 // 正常
@@ -15,13 +15,13 @@ const (
StatusUnactivated = 3 // 未激活
)
// Role constants.
// 角色常量。
const (
RoleAdmin = "admin"
RoleAuthor = "author"
)
// User represents a blog user (author / admin).
// User 表示博客用户(作者 / 管理员)。
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;not null;size:255" json:"username"`
@@ -36,13 +36,12 @@ type User struct {
Articles []Article `gorm:"foreignKey:AuthorID" json:"-"`
}
// bcryptCost is the work factor used when hashing new passwords
// (SECURITY_TODO #17). Existing hashes keep their cost — CompareHashAndPassword
// adapts per hash — and are naturally upgraded on the user's next password
// change.
// bcryptCost 是新密码哈希时使用的工作因子(SECURITY_TODO #17)。
// 现有哈希保留其原有成本——CompareHashAndPassword 会按哈希自适应——
// 并在用户下次修改密码时自然升级。
const bcryptCost = 12
// SetPassword hashes the plain-text password with bcrypt and stores it.
// SetPassword 使用 bcrypt 对明文密码进行哈希并存储。
func (u *User) SetPassword(plain string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
@@ -52,7 +51,7 @@ func (u *User) SetPassword(plain string) error {
return nil
}
// CheckPassword compares a plain-text password against the stored bcrypt hash.
// CheckPassword 将明文密码与存储的 bcrypt 哈希进行比对。
func (u *User) CheckPassword(plain string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain))
return err == nil