fix(mysql): add size constraint to WebDAVProperty string fields for MySQL compatibility (#3259)

This commit is contained in:
Pikachu Ren authored and GitHub committed 2026-10-24 23:58:43 +08:00
1 parent 25e4eb663f
commit 0497ee8538
6 files changed
+285 -49

No files matched your search

+3
View File
@@ -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 数据违反新建的唯一索引。
+71
View File
@@ -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
}
+135
View File
@@ -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")
}
}
+32 -32
View File
@@ -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"`
}
}
+17 -14
View File
@@ -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"`
}
+27 -3
View File
@@ -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[:])
}