增加文件表与文件操作记录表
- files 表:相对路径、MIME、扩展名、大小、sha256 唯一(秒传)、引用计数、上传者、存储后端、元数据等 - file_operations 表:create/download/rename/move/delete 五种操作,含文件快照、操作人快照与 IP - 迁移 v8、v9;补充 hash 唯一约束与操作日志读写测试
This commit is contained in:
4 files changed
+197
-23
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 记录已应用的迁移版本。
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user