refactor: 上传/下载接口全面迁移到统一 files 表,移除 attachments 模型
- 删除 models/attachment.go,Attachment 模型由 File(Type=attachments)替代 - handlers/attachment.go:上传/删除/列表/绑定全部改读写 files 表, 查询按 type='attachments' 过滤(为 avatar/logo 等类型预留隔离) - models/db.go:AutoMigrate 移除 Attachment;启动迁移仅当旧表仍存在时执行 (升级路径保障,已删除则为无操作) - 测试同步切换到 File 模型(security_test/bodylimit_test) - scripts/drop_attachments_table.sql:旧表删除脚本(含前置校验说明)
This commit is contained in:
+16
-11
@@ -76,6 +76,8 @@ func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
|
||||
// UploadAttachment 处理来自文章创建/编辑表单的 AJAX 附件上传。
|
||||
// 请求携带真实的 article_id(编辑页)或 session_token(创建页,待绑定)。
|
||||
// 文件按 SHA-256 内容寻址,实现磁盘去重。
|
||||
//
|
||||
// 记录写入全站统一的 files 表(Type=attachments),与历史数据同属一个表。
|
||||
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uploaderID, ok := sessionAuthorID(c)
|
||||
@@ -152,7 +154,8 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
att := models.Attachment{
|
||||
att := models.File{
|
||||
Type: models.FileTypeAttachment,
|
||||
ArticleID: articleID,
|
||||
SessionToken: token,
|
||||
UploaderID: uploaderID,
|
||||
@@ -181,14 +184,14 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
|
||||
// ---------------- 删除 ----------------
|
||||
|
||||
// DeleteAttachment 软删除附件记录,仅当没有其余记录引用时才删除磁盘文件
|
||||
// (引用计数,因为内容寻址的文件可能被共享)。只有管理员、上传者或
|
||||
// 文件所在文章的作者可以删除。
|
||||
// DeleteAttachment 软删除附件记录(files 表,Type=attachments),仅当
|
||||
// 没有其余记录引用时才删除磁盘文件(引用计数,因为内容寻址的文件可能
|
||||
// 被共享)。只有管理员、上传者或文件所在文章的作者可以删除。
|
||||
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := parseUintParam(c, "id")
|
||||
var att models.Attachment
|
||||
if err := db.First(&att, id).Error; err != nil {
|
||||
var att models.File
|
||||
if err := db.First(&att, "id = ? AND type = ?", id, models.FileTypeAttachment).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
@@ -214,7 +217,7 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
|
||||
// 引用计数:还有任何其他(未删除)行指向此文件吗?
|
||||
var count int64
|
||||
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
|
||||
db.Model(&models.File{}).Where("stored_name = ? AND type = ?", stored, models.FileTypeAttachment).Count(&count)
|
||||
if count == 0 {
|
||||
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // 忽略错误
|
||||
}
|
||||
@@ -225,6 +228,7 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
// ---------------- 列表 ----------------
|
||||
|
||||
// ListAttachments 以 JSON 返回文章的附件(供编辑页加载时重新填充列表)。
|
||||
// 只读取 files 表中 Type=attachments 的记录。
|
||||
// 只有文章作者(或管理员)可以列出。
|
||||
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -237,8 +241,9 @@ func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
var atts []models.Attachment
|
||||
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
|
||||
var atts []models.File
|
||||
db.Where("article_id = ? AND type = ?", articleID, models.FileTypeAttachment).
|
||||
Order("created_at ASC").Find(&atts)
|
||||
|
||||
out := make([]gin.H, 0, len(atts))
|
||||
for _, a := range atts {
|
||||
@@ -264,8 +269,8 @@ func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return db.Model(&models.Attachment{}).
|
||||
Where("session_token = ? AND article_id = 0", token).
|
||||
return db.Model(&models.File{}).
|
||||
Where("session_token = ? AND article_id = 0 AND type = ?", token, models.FileTypeAttachment).
|
||||
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestBodyLimitRejectsOversizedMultipart(t *testing.T) {
|
||||
}
|
||||
|
||||
var count int64
|
||||
e.db.Model(&models.Attachment{}).Where("filename = ?", "big.txt").Count(&count)
|
||||
e.db.Model(&models.File{}).Where("filename = ?", "big.txt").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("oversized multipart created %d attachment rows, want 0", count)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{},
|
||||
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.File{}, &models.SiteSetting{},
|
||||
&models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{},
|
||||
&models.DownloadBaseURL{}, &models.Comment{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
@@ -439,7 +439,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
|
||||
t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var bobAtt models.Attachment
|
||||
var bobAtt models.File
|
||||
if err := e.db.Where("uploader_id = ?", userIDByUsername(t, e.db, "bob")).First(&bobAtt).Error; err != nil {
|
||||
t.Fatalf("bob attachment not found: %v", err)
|
||||
}
|
||||
@@ -458,7 +458,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
|
||||
|
||||
// Bob 的附件记录应该已删除。
|
||||
var count int64
|
||||
e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count)
|
||||
e.db.Model(&models.File{}).Where("id = ?", bobAtt.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("attachment was not deleted")
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Attachment 表示附加到文章的文件。
|
||||
//
|
||||
// 生命周期(方案 A——先上传后绑定):
|
||||
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0,
|
||||
// 该行暂时由 SessionToken(为页面会话生成的随机值)持有。
|
||||
// - 保存文章时,ArticleCreate 通过 SessionToken 绑定待处理行,
|
||||
// 设置它们的 ArticleID 并清除令牌。
|
||||
// - 在编辑页面上,上传直接携带真实的 ArticleID。
|
||||
//
|
||||
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
|
||||
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
|
||||
// 删除采用引用计数——仅当没有任何 Attachment 行引用时,才删除磁盘文件。
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
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"` // 原始文件名
|
||||
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"`
|
||||
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// IsImage 报告该附件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
|
||||
func (a *Attachment) IsImage() bool {
|
||||
return a.Category == CategoryImage
|
||||
}
|
||||
+10
-4
@@ -84,12 +84,14 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// 自动迁移数据表(幂等操作)。
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &File{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
|
||||
// 自动迁移数据表(幂等操作)。attachments 旧表已由 files 替代,
|
||||
// 不再参与迁移;历史数据在下方 migrateAttachmentsToFiles 中一次性搬运。
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &File{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// 数据迁移:attachments → 全站统一 files 表(type='attachments'),幂等。
|
||||
// 仅当旧表仍存在时执行(已删除则为无操作),保证升级路径上的数据不丢。
|
||||
migrateAttachmentsToFiles(db)
|
||||
|
||||
// 首次运行时初始化站点平台配置。
|
||||
@@ -136,12 +138,16 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// migrateAttachmentsToFiles 将 attachments 表中的历史数据复制到统一的
|
||||
// files 表,type 一律标记为 "attachments"。
|
||||
// migrateAttachmentsToFiles 将旧 attachments 表中的历史数据复制到统一的
|
||||
// files 表,type 一律标记为 "attachments"。仅当 attachments 表仍存在时
|
||||
// 执行——新安装从未创建过该表,而已经切换的部署会将其删除。
|
||||
//
|
||||
// 幂等策略:以主键 id 对齐——files 中已存在同 id 的行视为已迁移并跳过,
|
||||
// 因此 InitDB 每次启动重复执行也不会产生重复数据(含软删除行一并复制)。
|
||||
func migrateAttachmentsToFiles(db *gorm.DB) {
|
||||
if !db.Migrator().HasTable("attachments") {
|
||||
return
|
||||
}
|
||||
res := db.Exec(`
|
||||
INSERT INTO files
|
||||
(id, type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
|
||||
|
||||
+21
-11
@@ -7,19 +7,24 @@ import (
|
||||
)
|
||||
|
||||
// File 是全站统一的上传文件登记表,用于管理所有类型的上传文件
|
||||
// (附件、头像、Logo 等)。字段与 Attachment 对齐,额外通过 Type
|
||||
// 字段区分文件归属类型。
|
||||
// (附件、头像、Logo 等)。字段与原 attachments 表对齐,额外通过
|
||||
// Type 字段区分文件归属类型。
|
||||
//
|
||||
// 说明:
|
||||
// - attachments 表中的历史数据在启动迁移时复制到本表,Type 一律
|
||||
// 标记为 "attachments"(见 db.go 的 migrateAttachmentsToFiles)。
|
||||
// - 磁盘去重与引用计数沿用附件策略:StoredName 为内容 SHA-256,
|
||||
// 删除时仅当没有任何行引用时才删磁盘文件。
|
||||
// 生命周期(方案 A——先上传后绑定):
|
||||
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0,
|
||||
// 该行暂时由 SessionToken(为页面会话生成的随机值)持有。
|
||||
// - 保存文章时,ArticleCreate 通过 SessionToken 绑定待处理行,
|
||||
// 设置它们的 ArticleID 并清除令牌。
|
||||
// - 在编辑页面上,上传直接携带真实的 ArticleID。
|
||||
//
|
||||
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
|
||||
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
|
||||
// 删除采用引用计数——仅当没有任何 File 行引用时,才删除磁盘文件。
|
||||
type File struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Type string `gorm:"size:32;index;default:attachments" json:"type"` // 归属类型:attachments/avatar/logo/...
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 在创建页面上待绑定时为 0
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // 创建页面上的临时归属令牌
|
||||
Type string `gorm:"size:32;index;default:attachments" json:"type"` // 归属类型:attachment 等
|
||||
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"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 十六进制字符串,磁盘文件名
|
||||
@@ -37,5 +42,10 @@ func (File) TableName() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
// FileTypeAttachment 是文件归属类型常量:来自 attachments 表的历史附件。
|
||||
// FileTypeAttachment 是文件归属类型常量:文章附件(原 attachments 表历史数据)。
|
||||
const FileTypeAttachment = "attachments"
|
||||
|
||||
// IsImage 报告该文件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
|
||||
func (f *File) IsImage() bool {
|
||||
return f.Category == CategoryImage
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: 删除旧 attachments 表(数据已迁移至统一 files 表,type='attachments')
|
||||
-- Date: 2026-08-28
|
||||
-- 前置条件:已部署使用 files 表的新版 blog_go 并完成验证(启动迁移会把
|
||||
-- 尚未搬运的 attachments 行复制进 files)。本脚本幂等,可安全重跑。
|
||||
--
|
||||
-- 执行前确认:
|
||||
-- SELECT COUNT(*) AS remaining FROM attachments a
|
||||
-- WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = a.id);
|
||||
-- 结果应为 0——若有遗留行,先重启新版本服务让其自动搬运。
|
||||
|
||||
DROP TABLE IF EXISTS `attachments`;
|
||||
Reference in New Issue
Block a user