From 0497ee853853eff5fc0ada9f21684a990887de83 Mon Sep 17 00:00:00 2001 From: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:58:43 +0800 Subject: [PATCH] fix(mysql): add size constraint to WebDAVProperty string fields for MySQL compatibility (#3259) --- internal/db/db.go | 3 + internal/db/webdav_property_migrate.go | 71 ++++++++++ internal/db/webdav_property_migrate_test.go | 135 ++++++++++++++++++++ internal/model/media.go | 64 +++++----- internal/model/media_extra.go | 31 +++-- internal/model/webdav_property.go | 30 ++++- 6 files changed, 285 insertions(+), 49 deletions(-) create mode 100644 internal/db/webdav_property_migrate.go create mode 100644 internal/db/webdav_property_migrate_test.go diff --git a/internal/db/db.go b/internal/db/db.go index 85864800..eb0c2f68 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -22,6 +22,9 @@ func Init(d *gorm.DB) { if err := migrateMediaItems(); err != nil { log.Fatalf("failed migrate media_items: %s (如果旧数据无法原地迁移,请先备份数据库,再设置 %s=1 重建媒体表)", err.Error(), MediaMigrateWipeEnv) } + if err := migrateWebDAVProperties(); err != nil { + log.Fatalf("failed migrate webdav_properties: %s", err.Error()) + } err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.WebDAVProperty), new(model.MediaItem), new(model.MediaConfig), new(model.MediaScanPath), new(model.MediaAppKey), new(model.MediaUserData), new(model.MediaPlaylist), new(model.MediaPlaylistItem), new(model.EmbyDevice), new(model.MediaAnnotation)) if err != nil { // 典型场景:未确认清库时,旧 media_items 数据违反新建的唯一索引。 diff --git a/internal/db/webdav_property_migrate.go b/internal/db/webdav_property_migrate.go new file mode 100644 index 00000000..345a5c67 --- /dev/null +++ b/internal/db/webdav_property_migrate.go @@ -0,0 +1,71 @@ +package db + +import ( + "database/sql" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "gorm.io/gorm" +) + +// migrateWebDAVProperties prepares existing dead properties for the exact +// hash-based unique index used by WebDAVProperty. +func migrateWebDAVProperties() error { + table, err := webdavPropertiesTable() + if err != nil { + return err + } + m := db.Migrator() + if !m.HasTable(table) { + return nil + } + + if !m.HasColumn(table, "key_hash") { + if err := m.AddColumn(&model.WebDAVProperty{}, "KeyHash"); err != nil { + return err + } + } + + var rows []struct { + ID uint + Path string + Namespace string + Name string + KeyHash sql.NullString + } + if err := db.Model(&model.WebDAVProperty{}).Select("id, path, namespace, name, key_hash").Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + hash := model.HashWebDAVPropertyKey(row.Path, row.Namespace, row.Name) + if row.KeyHash.Valid && row.KeyHash.String == hash { + continue + } + if err := db.Model(&model.WebDAVProperty{}).Where("id = ?", row.ID).UpdateColumn("key_hash", hash).Error; err != nil { + return err + } + } + + if m.HasIndex(table, "idx_webdav_property") { + indexes, err := m.GetIndexes(&model.WebDAVProperty{}) + if err != nil { + return err + } + for _, index := range indexes { + if index.Name() == "idx_webdav_property" && (len(index.Columns()) != 1 || index.Columns()[0] != "key_hash") { + if err := m.DropIndex(table, index.Name()); err != nil { + return err + } + break + } + } + } + return nil +} + +func webdavPropertiesTable() (string, error) { + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(&model.WebDAVProperty{}); err != nil { + return "", err + } + return stmt.Schema.Table, nil +} diff --git a/internal/db/webdav_property_migrate_test.go b/internal/db/webdav_property_migrate_test.go new file mode 100644 index 00000000..7b37a2c4 --- /dev/null +++ b/internal/db/webdav_property_migrate_test.go @@ -0,0 +1,135 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" + "sync" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/migrator" + "gorm.io/gorm/schema" +) + +// MySQL rejects indexes on TEXT columns without a key prefix length +// (Error 1170), see #3258. The unique index must therefore use the fixed-size +// hash column rather than a prefix of the full WebDAV property key. +func TestWebDAVPropertyMySQLIndexUsesKeyHash(t *testing.T) { + conn, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:1)/db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + d, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{ + DisableAutomaticPing: true, + }) + if err != nil { + t.Fatal(err) + } + + s, err := schema.Parse(&model.WebDAVProperty{}, &sync.Map{}, d.NamingStrategy) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Path", "Namespace", "Name"} { + if got := strings.ToLower(d.Dialector.DataTypeOf(s.LookUpField(name))); got != "text" { + t.Fatalf("%s column type = %q, want text", name, got) + } + } + if got := strings.ToLower(d.Dialector.DataTypeOf(s.LookUpField("KeyHash"))); got != "varchar(64)" { + t.Fatalf("KeyHash column type = %q, want varchar(64)", got) + } + + idx := s.LookIndex("idx_webdav_property") + if idx == nil || idx.Class != "UNIQUE" { + t.Fatalf("unique index idx_webdav_property not found: %+v", idx) + } + stmt := &gorm.Statement{DB: d, Schema: s} + opts := d.Migrator().(migrator.BuildIndexOptionsInterface).BuildIndexOptions(idx.Fields, stmt) + if len(opts) != 1 || opts[0].(clause.Expr).SQL != "`key_hash`" { + t.Fatalf("index parts = %v, want `key_hash`", opts) + } +} + +func TestWebDAVPropertyLongPathSQLite(t *testing.T) { + d, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := d.AutoMigrate(&model.WebDAVProperty{}); err != nil { + t.Fatal(err) + } + if !d.Migrator().HasIndex(&model.WebDAVProperty{}, "idx_webdav_property") { + t.Fatal("idx_webdav_property not created") + } + // Re-running AutoMigrate on an existing table must be a no-op. + if err := d.AutoMigrate(&model.WebDAVProperty{}); err != nil { + t.Fatal(err) + } + + long := "/" + strings.Repeat("目录/", 400) + "file.txt" + row := model.WebDAVProperty{Path: long, Namespace: "DAV:", Name: "displayname", InnerXML: []byte("x")} + if err := d.Create(&row).Error; err != nil { + t.Fatalf("insert long path: %v", err) + } + var got model.WebDAVProperty + if err := d.Where("path = ?", long).First(&got).Error; err != nil { + t.Fatal(err) + } + if got.Path != long { + t.Fatalf("path truncated: len %d, want %d", len(got.Path), len(long)) + } + if got.KeyHash != model.HashWebDAVPropertyKey(long, "DAV:", "displayname") { + t.Fatalf("key hash = %q, want hash of complete property key", got.KeyHash) + } + dup := model.WebDAVProperty{Path: long, Namespace: "DAV:", Name: "displayname"} + if err := d.Create(&dup).Error; err == nil { + t.Fatal("duplicate (path, namespace, name) was accepted") + } + other := model.WebDAVProperty{Path: long + "2", Namespace: "DAV:", Name: "displayname"} + if err := d.Create(&other).Error; err != nil { + t.Fatal(fmt.Errorf("distinct path rejected: %w", err)) + } +} + +func TestMigrateWebDAVPropertiesBackfillsKeyHash(t *testing.T) { + d := openTestDB(t, "ol_") + db = d + if err := d.Exec(`CREATE TABLE ol_web_dav_properties ( + id integer primary key autoincrement, + path text, + namespace text, + name text, + lang text, + inner_xml blob, + UNIQUE (path, namespace, name) + )`).Error; err != nil { + t.Fatal(err) + } + path := "/" + strings.Repeat("目录/", 400) + "file.txt" + if err := d.Exec("INSERT INTO ol_web_dav_properties (path, namespace, name) VALUES (?, ?, ?)", path, "DAV:", "displayname").Error; err != nil { + t.Fatal(err) + } + + if err := migrateWebDAVProperties(); err != nil { + t.Fatal(err) + } + if err := d.AutoMigrate(&model.WebDAVProperty{}); err != nil { + t.Fatal(err) + } + var got model.WebDAVProperty + if err := d.Where("path = ?", path).First(&got).Error; err != nil { + t.Fatal(err) + } + if got.KeyHash != model.HashWebDAVPropertyKey(path, "DAV:", "displayname") { + t.Fatalf("migrated key hash = %q, want hash of complete property key", got.KeyHash) + } + if !d.Migrator().HasIndex("ol_web_dav_properties", "idx_webdav_property") { + t.Fatal("idx_webdav_property not recreated") + } +} diff --git a/internal/model/media.go b/internal/model/media.go index e7713e8e..56b0b357 100644 --- a/internal/model/media.go +++ b/internal/model/media.go @@ -18,20 +18,19 @@ const ( // MediaScanPath 媒体库扫描路径(每个媒体库可配置多个扫描路径) type MediaScanPath struct { - gorm.Model ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - MediaType MediaType `gorm:"index;not null" json:"media_type"` // 所属媒体类型 - Name string `json:"name"` // 路径名称(用于前端筛选显示) - Path string `gorm:"not null" json:"path"` // VFS 扫描路径 - PathMerge bool `gorm:"default:false" json:"path_merge"` // 路径合并模式(子文件夹作为一个条目) - TypeTag string `json:"type_tag"` // 类型标签:电影、电视剧等 - ContentTags string `json:"content_tags"` // 内容标签:喜剧、惊悚等(逗号分隔) - EnableScrape bool `gorm:"default:true" json:"enable_scrape"` // 是否启用刮削 - LastScanAt *time.Time `json:"last_scan_at"` // 最后扫描时间 + MediaType MediaType `gorm:"size:32;index;not null" json:"media_type"` // 所属媒体类型 + Name string `json:"name"` // 路径名称(用于前端筛选显示) + Path string `gorm:"not null" json:"path"` // VFS 扫描路径 + PathMerge bool `gorm:"default:false" json:"path_merge"` // 路径合并模式(子文件夹作为一个条目) + TypeTag string `json:"type_tag"` // 类型标签:电影、电视剧等 + ContentTags string `json:"content_tags"` // 内容标签:喜剧、惊悚等(逗号分隔) + EnableScrape bool `gorm:"default:true" json:"enable_scrape"` // 是否启用刮削 + LastScanAt *time.Time `json:"last_scan_at"` // 最后扫描时间 } // MediaIndexColumnMax 复合唯一索引列(folder_path / file_name / album_name)的最大字符数 @@ -39,43 +38,43 @@ const MediaIndexColumnMax = 191 // MediaItem 媒体条目(统一表,通过 media_type 区分类型,便于后期扩展新类型) type MediaItem struct { - gorm.Model - // 覆盖 gorm.Model 的 ID 字段,使 JSON 序列化为小写 "id",与前端保持一致 + // 显式声明基础字段,避免同时嵌入 gorm.Model 后重复生成 deleted_at 索引列; + // JSON 标签继续保持媒体 API 的字段命名约定。 ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 基础信息 - MediaType MediaType `gorm:"index;not null" json:"media_type"` + MediaType MediaType `gorm:"size:32;index;not null" json:"media_type"` ScanPathID uint `gorm:"index" json:"scan_path_id"` // 关联的扫描路径ID // 三个字段共同组成复合唯一索引。MySQL 对「有索引且未指定长度」的 string 会生成 longtext, // 而 TEXT 列不能直接建索引(AutoMigrate 会失败);因此必须显式限定 size。 // utf8mb4 下 InnoDB 索引总长上限 3072 字节:191*3*4 = 2292,留足余量。 - FileName string `gorm:"size:191;uniqueIndex:idx_media_folder_file_album" json:"file_name"` - FileSize int64 `json:"file_size"` - MimeType string `json:"mime_type"` - Hidden bool `gorm:"default:false" json:"hidden"` + FileName string `gorm:"size:191;uniqueIndex:idx_media_folder_file_album" json:"file_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Hidden bool `gorm:"default:false" json:"hidden"` // 刮削/编辑信息 ScrapedName string `json:"scraped_name"` Description string `gorm:"type:text" json:"description"` - Cover string `json:"cover"` // 封面URL或本地路径 - ReleaseDate string `json:"release_date"` // 发布时间,格式 YYYY-MM-DD - Rating float32 `json:"rating"` // 评分 0-10 - Genre string `json:"genre"` // 类型,逗号分隔,如 "动作,科幻" - Authors string `gorm:"type:text" json:"authors"` // 作者/演员,JSON数组字符串 - Plot string `gorm:"type:text" json:"plot"` // 剧情/内容介绍 - Reviews string `gorm:"type:text" json:"reviews"` // 用户评价,JSON数组字符串 + Cover string `json:"cover"` // 封面URL或本地路径 + ReleaseDate string `json:"release_date"` // 发布时间,格式 YYYY-MM-DD + Rating float32 `json:"rating"` // 评分 0-10 + Genre string `json:"genre"` // 类型,逗号分隔,如 "动作,科幻" + Authors string `gorm:"type:text" json:"authors"` // 作者/演员,JSON数组字符串 + Plot string `gorm:"type:text" json:"plot"` // 剧情/内容介绍 + Reviews string `gorm:"type:text" json:"reviews"` // 用户评价,JSON数组字符串 // 外部ID(用于刮削关联) ExternalID string `json:"external_id"` // TMDB ID / Discogs ID / 豆瓣ID // 音乐专属字段 AlbumName string `gorm:"size:191;uniqueIndex:idx_media_folder_file_album" json:"album_name"` // 所属专辑名 - AlbumArtist string `json:"album_artist"` // 专辑艺术家 - TrackNumber int `json:"track_number"` // 曲目编号 - Duration int `json:"duration"` // 时长(秒) - Lyrics string `gorm:"type:text" json:"lyrics"` // LRC格式歌词 + AlbumArtist string `json:"album_artist"` // 专辑艺术家 + TrackNumber int `json:"track_number"` // 曲目编号 + Duration int `json:"duration"` // 时长(秒) + Lyrics string `gorm:"type:text" json:"lyrics"` // LRC格式歌词 // 视频专属字段 VideoType string `json:"video_type"` // "movie" 或 "tv" @@ -87,21 +86,22 @@ type MediaItem struct { ISBN string `json:"isbn"` // ISBN // 目录合并模式 - IsFolder bool `gorm:"default:false" json:"is_folder"` // 是否为文件夹模式条目 + IsFolder bool `gorm:"default:false" json:"is_folder"` // 是否为文件夹模式条目 FolderPath string `gorm:"size:191;index;uniqueIndex:idx_media_folder_file_album" json:"folder_path"` // 扫描根路径(恒定为扫描路径,与file_name+album_name组合唯一) - Episodes string `gorm:"type:text" json:"episodes"` // 选集信息,JSON数组,格式:[{file_name,index,title},...] + Episodes string `gorm:"type:text" json:"episodes"` // 选集信息,JSON数组,格式:[{file_name,index,title},...] ScrapedAt *time.Time `json:"scraped_at"` } // MediaConfig 媒体库配置(每种类型一条记录) type MediaConfig struct { - gorm.Model ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - MediaType MediaType `gorm:"uniqueIndex;not null" json:"media_type"` + // 媒体类型只允许使用 MediaType 定义的短枚举值;显式长度使 MySQL 生成 + // varchar 而不是无法建立索引的 longtext,满足该字段的唯一约束。 + MediaType MediaType `gorm:"size:32;uniqueIndex;not null" json:"media_type"` Enabled bool `gorm:"default:false" json:"enabled"` LastScanAt *time.Time `json:"last_scan_at"` LastScrapeAt *time.Time `json:"last_scrape_at"` @@ -126,4 +126,4 @@ type MediaScanProgress struct { Done int `json:"done"` Message string `json:"message"` Error string `json:"error,omitempty"` -} \ No newline at end of file +} diff --git a/internal/model/media_extra.go b/internal/model/media_extra.go index 658cfa4f..212b03ab 100644 --- a/internal/model/media_extra.go +++ b/internal/model/media_extra.go @@ -11,8 +11,10 @@ type MediaAppKey struct { UpdatedAt time.Time `json:"updated_at"` UserID uint `gorm:"index;not null" json:"user_id"` - Name string `json:"name"` // 备注名,例如 "Symfonium"、"MoviePilot" - Prefix string `gorm:"index" json:"prefix"` // 明文前缀(便于识别/排查) + Name string `json:"name"` // 备注名,例如 "Symfonium"、"MoviePilot" + // 前缀由 AppKeyPrefix 固定为最多 8 个 ASCII 字符;显式长度使 MySQL + // 将索引列建为 varchar,而不是无法建立索引的 longtext。 + Prefix string `gorm:"size:32;index" json:"prefix"` // 明文前缀(便于识别/排查) Secret string `gorm:"not null" json:"-"` // AES-GCM 密文(base64) LastUsedAt *time.Time `json:"last_used_at"` @@ -31,7 +33,7 @@ type MediaUserData struct { Played bool `json:"played"` PlayCount int `json:"play_count"` IsFavorite bool `gorm:"index" json:"is_favorite"` - Rating int `json:"rating"` // 0-5,Subsonic 语义 + Rating int `json:"rating"` // 0-5,Subsonic 语义 PositionTicks int64 `json:"position_ticks"` // 100ns tick,与 Emby 一致 LastPlayedAt *time.Time `json:"last_played_at"` } @@ -51,11 +53,11 @@ type MediaPlaylist struct { // MediaPlaylistItem 播放列表条目(按 Order 排序) type MediaPlaylistItem struct { - ID uint `gorm:"primarykey" json:"id"` - PlaylistID uint `gorm:"index;not null" json:"playlist_id"` - ItemID uint `gorm:"index;not null" json:"item_id"` - EpisodeIndex int `json:"episode_index"` - Order int `gorm:"column:sort_order" json:"order"` + ID uint `gorm:"primarykey" json:"id"` + PlaylistID uint `gorm:"index;not null" json:"playlist_id"` + ItemID uint `gorm:"index;not null" json:"item_id"` + EpisodeIndex int `json:"episode_index"` + Order int `gorm:"column:sort_order" json:"order"` } // MediaAnnotation 用户在 PDF 阅读器里添加的批注。 @@ -80,12 +82,13 @@ type EmbyDevice struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` - UserID uint `gorm:"index;not null" json:"user_id"` - DeviceID string `json:"device_id"` // 客户端上报的 DeviceId - DeviceName string `json:"device_name"` - Client string `json:"client"` - Version string `json:"version"` - TokenHash string `gorm:"uniqueIndex;not null" json:"-"` + UserID uint `gorm:"index;not null" json:"user_id"` + DeviceID string `json:"device_id"` // 客户端上报的 DeviceId + DeviceName string `json:"device_name"` + Client string `json:"client"` + Version string `json:"version"` + // HashToken 始终返回 SHA-256 的 64 个十六进制字符,长度固定且适合唯一索引。 + TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"` AppKeyID uint `json:"app_key_id"` // 通过应用密钥认证时为对应 key,否则为 0 LastSeenAt time.Time `json:"last_seen_at"` } diff --git a/internal/model/webdav_property.go b/internal/model/webdav_property.go index cdfedb9e..00a78b8b 100644 --- a/internal/model/webdav_property.go +++ b/internal/model/webdav_property.go @@ -1,11 +1,35 @@ package model +import ( + "crypto/sha256" + "encoding/hex" + + "gorm.io/gorm" +) + // WebDAVProperty stores a dead WebDAV property for a resource. +// +// Path, Namespace and Name are TEXT columns so that long WebDAV paths are +// never truncated or rejected. MySQL cannot index TEXT columns without a key +// prefix length, so the unique index uses a SHA-256 hash of the complete +// property key instead. type WebDAVProperty struct { ID uint `json:"id" gorm:"primaryKey"` - Path string `json:"path" gorm:"uniqueIndex:idx_webdav_property"` - Namespace string `json:"namespace" gorm:"uniqueIndex:idx_webdav_property"` - Name string `json:"name" gorm:"uniqueIndex:idx_webdav_property"` + Path string `json:"path" gorm:"type:text"` + Namespace string `json:"namespace" gorm:"type:text"` + Name string `json:"name" gorm:"type:text"` + KeyHash string `json:"-" gorm:"size:64;uniqueIndex:idx_webdav_property"` Lang string `json:"lang"` InnerXML []byte `json:"inner_xml"` } + +func (p *WebDAVProperty) BeforeSave(*gorm.DB) error { + p.KeyHash = HashWebDAVPropertyKey(p.Path, p.Namespace, p.Name) + return nil +} + +// HashWebDAVPropertyKey returns a stable hash for the complete property key. +func HashWebDAVPropertyKey(path, namespace, name string) string { + sum := sha256.Sum256([]byte(path + "\x00" + namespace + "\x00" + name)) + return hex.EncodeToString(sum[:]) +}