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:
48 files changed
+983
-1080
No files matched your search
+19
-19
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 #15:Gravatar 可通过反向查询暴露 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
@@ -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 desc、priority 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
@@ -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
@@ -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
@@ -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
@@ -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 #15:Gravatar 可通过反向查询暴露 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
@@ -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/订阅链接的规范化站点基础 URL(SECURITY_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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user