From 6949b12af09a5fbb52e48f09c8a8654bf7d3fbaa Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 21 Sep 2026 20:28:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=87=E4=BB=B6=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E6=96=87=E4=BB=B6=E6=93=8D=E4=BD=9C=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - files 表:相对路径、MIME、扩展名、大小、sha256 唯一(秒传)、引用计数、上传者、存储后端、元数据等 - file_operations 表:create/download/rename/move/delete 五种操作,含文件快照、操作人快照与 IP - 迁移 v8、v9;补充 hash 唯一约束与操作日志读写测试 --- internal/database/database_test.go | 149 ++++++++++++++++++++++++----- internal/database/migrate.go | 14 +++ internal/model/file.go | 28 ++++++ internal/model/file_operation.go | 29 ++++++ 4 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 internal/model/file.go create mode 100644 internal/model/file_operation.go diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 364e923..8d83f33 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -2,6 +2,7 @@ package database import ( "context" + "errors" "os" "path/filepath" "strings" @@ -177,29 +178,6 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) { } } -func TestGeneratePassword(t *testing.T) { - password, err := generatePassword(adminPasswordLen) - if err != nil { - t.Fatalf("生成密码失败: %v", err) - } - if len(password) != adminPasswordLen { - t.Errorf("密码长度 = %d, 期望 %d", len(password), adminPasswordLen) - } - for _, r := range password { - if !strings.ContainsRune(adminPasswordCharset, r) { - t.Errorf("密码包含非法字符 %q", r) - } - } - - other, err := generatePassword(adminPasswordLen) - if err != nil { - t.Fatalf("生成密码失败: %v", err) - } - if password == other { - t.Error("两次生成的密码不应相同") - } -} - func TestAddProfileFieldsMigration(t *testing.T) { db := openTestDB(t) ctx := context.Background() @@ -318,3 +296,128 @@ func TestTranslateBuiltinDataMigration(t *testing.T) { t.Errorf("自定义昵称被覆盖: %q", admin.Nickname) } } + +func TestFileRecord(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + if err := Migrate(ctx, db); err != nil { + t.Fatalf("执行迁移失败: %v", err) + } + + for _, column := range []string{ + "name", "path", "extension", "mime_type", "size", "hash", "ref_count", + "uploader_id", "storage", "status", "metadata", "last_referenced_at", + } { + if !db.Migrator().HasColumn(&model.File{}, column) { + t.Errorf("files 表缺少列 %s", column) + } + } + + uploaderID := uint(1) + file := model.File{ + Name: "avatar.png", + Path: "2026/09/21/abc.png", + Extension: "png", + MimeType: "image/png", + Size: 1024, + Hash: "abc123", + UploaderID: &uploaderID, + Metadata: `{"width":800}`, + } + if err := db.WithContext(ctx).Create(&file).Error; err != nil { + t.Fatalf("创建文件记录失败: %v", err) + } + + var got model.File + if err := db.WithContext(ctx).First(&got, file.ID).Error; err != nil { + t.Fatalf("查询文件记录失败: %v", err) + } + if got.Name != "avatar.png" || got.Size != 1024 || got.MimeType != "image/png" { + t.Errorf("文件记录异常: %+v", got) + } + if got.RefCount != 0 || got.Status != model.FileStatusEnabled || got.Storage != "local" { + t.Errorf("默认值异常: ref_count=%d status=%d storage=%q", got.RefCount, got.Status, got.Storage) + } + if got.UploaderID == nil || *got.UploaderID != uploaderID { + t.Errorf("uploader_id 异常: %v", got.UploaderID) + } + + duplicate := model.File{Name: "copy.png", Path: "2026/09/21/copy.png", Hash: "abc123"} + if err := db.WithContext(ctx).Create(&duplicate).Error; !errors.Is(err, gorm.ErrDuplicatedKey) { + t.Errorf("同 hash 重复插入应冲突, err=%v", err) + } + + if err := db.WithContext(ctx).Delete(&got).Error; err != nil { + t.Fatalf("删除文件记录失败: %v", err) + } + again := model.File{Name: "again.png", Path: "2026/09/21/again.png", Hash: "abc123"} + if err := db.WithContext(ctx).Create(&again).Error; err != nil { + t.Errorf("删除后应可重新插入同 hash: %v", err) + } +} + +func TestFileOperationRecord(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + if err := Migrate(ctx, db); err != nil { + t.Fatalf("执行迁移失败: %v", err) + } + + if db.Migrator().HasColumn(&model.FileOperation{}, "updated_at") { + t.Error("操作日志是追加型记录,不应有 updated_at 列") + } + + operatorID := uint(1) + operations := []model.FileOperation{ + { + FileID: 1, FileName: "a.png", FileHash: "h1", Operation: model.FileOperationCreate, + OperatorID: &operatorID, Operator: "admin", PathAfter: "2026/a.png", NameAfter: "a.png", IP: "127.0.0.1", + }, + { + FileID: 1, FileName: "a.png", FileHash: "h1", Operation: model.FileOperationDownload, + OperatorID: &operatorID, Operator: "admin", PathBefore: "2026/a.png", PathAfter: "2026/a.png", IP: "127.0.0.1", + }, + { + FileID: 1, FileName: "b.png", FileHash: "h1", Operation: model.FileOperationRename, + OperatorID: &operatorID, Operator: "admin", NameBefore: "a.png", NameAfter: "b.png", IP: "127.0.0.1", + }, + { + FileID: 1, FileName: "b.png", FileHash: "h1", Operation: model.FileOperationMove, + OperatorID: &operatorID, Operator: "admin", PathBefore: "2026/a.png", PathAfter: "archive/b.png", IP: "127.0.0.1", + }, + { + FileID: 1, FileName: "b.png", FileHash: "h1", Operation: model.FileOperationDelete, + PathBefore: "archive/b.png", NameBefore: "b.png", IP: "127.0.0.1", + }, + } + if err := db.WithContext(ctx).Create(&operations).Error; err != nil { + t.Fatalf("创建操作日志失败: %v", err) + } + + var count int64 + if err := db.WithContext(ctx).Model(&model.FileOperation{}).Count(&count).Error; err != nil { + t.Fatalf("统计操作日志失败: %v", err) + } + if count != int64(len(operations)) { + t.Errorf("操作日志数量 = %d, 期望 %d", count, len(operations)) + } + + var rename model.FileOperation + if err := db.WithContext(ctx).Where("operation = ?", model.FileOperationRename).First(&rename).Error; err != nil { + t.Fatalf("查询重命名日志失败: %v", err) + } + if rename.NameBefore != "a.png" || rename.NameAfter != "b.png" || rename.Operator != "admin" || rename.IP != "127.0.0.1" { + t.Errorf("重命名日志异常: %+v", rename) + } + + var deleted model.FileOperation + if err := db.WithContext(ctx).Where("operation = ?", model.FileOperationDelete).First(&deleted).Error; err != nil { + t.Fatalf("查询删除日志失败: %v", err) + } + if deleted.OperatorID != nil || deleted.Operator != "" { + t.Errorf("系统操作应无操作人: %+v", deleted) + } + if deleted.PathBefore != "archive/b.png" || deleted.PathAfter != "" { + t.Errorf("删除日志路径异常: %+v", deleted) + } +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 9423a5e..4227e4e 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -104,6 +104,20 @@ var migrations = []Migration{ ).Error }, }, + { + Version: 8, + Name: "create_files", + Up: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.File{}) + }, + }, + { + Version: 9, + Name: "create_file_operations", + Up: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.FileOperation{}) + }, + }, } // schemaMigration 记录已应用的迁移版本。 diff --git a/internal/model/file.go b/internal/model/file.go new file mode 100644 index 0000000..ad6ea8d --- /dev/null +++ b/internal/model/file.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// 文件状态。 +const ( + FileStatusDisabled int8 = 0 + FileStatusEnabled int8 = 1 +) + +// File 上传文件元数据。Path 相对存储根目录,Hash 唯一用于秒传去重。 +type File struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Path string `gorm:"size:512;not null" json:"path"` + Extension string `gorm:"size:20" json:"extension"` + MimeType string `gorm:"size:100" json:"mime_type"` + Size int64 `gorm:"not null;default:0" json:"size"` + Hash string `gorm:"size:64;uniqueIndex;not null" json:"hash"` + RefCount int64 `gorm:"not null;default:0" json:"ref_count"` + UploaderID *uint `gorm:"index" json:"uploader_id"` + Storage string `gorm:"size:20;not null;default:'local'" json:"storage"` + Status int8 `gorm:"not null;default:1" json:"status"` + Metadata string `gorm:"type:text" json:"metadata"` + LastReferencedAt *time.Time `json:"last_referenced_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/model/file_operation.go b/internal/model/file_operation.go new file mode 100644 index 0000000..c584f9b --- /dev/null +++ b/internal/model/file_operation.go @@ -0,0 +1,29 @@ +package model + +import "time" + +// 文件操作类型。 +const ( + FileOperationCreate = "create" + FileOperationDelete = "delete" + FileOperationMove = "move" + FileOperationRename = "rename" + FileOperationDownload = "download" +) + +// FileOperation 文件操作日志,追加型记录;无外键,文件物理删除后日志仍保留。 +type FileOperation struct { + ID uint `gorm:"primaryKey" json:"id"` + FileID uint `gorm:"index;not null" json:"file_id"` + FileName string `gorm:"size:255" json:"file_name"` + FileHash string `gorm:"size:64" json:"file_hash"` + Operation string `gorm:"size:20;not null" json:"operation"` + OperatorID *uint `gorm:"index" json:"operator_id"` + Operator string `gorm:"size:50" json:"operator"` + PathBefore string `gorm:"size:512" json:"path_before"` + PathAfter string `gorm:"size:512" json:"path_after"` + NameBefore string `gorm:"size:255" json:"name_before"` + NameAfter string `gorm:"size:255" json:"name_after"` + IP string `gorm:"size:45" json:"ip"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +}