mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +08:00
优化
This commit is contained in:
@@ -66,5 +66,7 @@ func AllModels() []interface{} {
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderReplaceRule{},
|
||||
&ReaderProfile{},
|
||||
&ReaderBookGroups{},
|
||||
}
|
||||
}
|
||||
|
||||
+53
-21
@@ -10,20 +10,20 @@ import (
|
||||
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
|
||||
type ReaderBookSource struct {
|
||||
Base
|
||||
Name string `gorm:"type:varchar(255);index" json:"name"`
|
||||
GroupName string `gorm:"type:varchar(255);index" json:"group"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
|
||||
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
|
||||
RawJSON string `gorm:"type:text" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
|
||||
CustomOrder int `json:"custom_order"`
|
||||
Weight int `json:"weight"`
|
||||
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
|
||||
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
|
||||
LastUpdateTime int64 `json:"last_update_time"`
|
||||
Name string `gorm:"type:varchar(255);index" json:"name"`
|
||||
GroupName string `gorm:"type:varchar(255);index" json:"group"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
|
||||
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
|
||||
RawJSON string `gorm:"type:text" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
|
||||
CustomOrder int `json:"custom_order"`
|
||||
Weight int `json:"weight"`
|
||||
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
|
||||
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
|
||||
LastUpdateTime int64 `json:"last_update_time"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||
@@ -58,13 +58,17 @@ type ReaderBook struct {
|
||||
Charset string `gorm:"type:varchar(32)" json:"charset"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片
|
||||
LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。
|
||||
// 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。
|
||||
// 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。
|
||||
LatestChapterTime int64 `json:"latest_chapter_time"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名
|
||||
// (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。
|
||||
// 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。
|
||||
@@ -108,3 +112,31 @@ type ReaderReplaceRule struct {
|
||||
TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// ReaderProfile 阅读器偏好(每个用户一条)。
|
||||
//
|
||||
// 主题 / 排版 / 听书 / 书架展示这些设置原先只存在浏览器 localStorage,属于设备级:
|
||||
// 换设备或换浏览器就丢。这里按用户落库作为权威来源,前端 localStorage 退化成首屏缓存。
|
||||
// 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不在这里同步。
|
||||
type ReaderProfile struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
|
||||
|
||||
ThemeID string `gorm:"type:varchar(32)" json:"theme_id"`
|
||||
Night bool `json:"night"`
|
||||
PageMode string `gorm:"type:varchar(16)" json:"page_mode"` // page 翻页 / scroll 滚动
|
||||
FontSize int `json:"font_size"`
|
||||
LineHeight float64 `json:"line_height"`
|
||||
ParagraphSpacing int `json:"paragraph_spacing"`
|
||||
|
||||
// 听书偏好(对应 legado AudioPlay.playSpeed 与 AppConfig.ttsTimer)
|
||||
AudioSpeed float64 `json:"audio_speed"`
|
||||
AudioTimerMinutes int `json:"audio_timer_minutes"`
|
||||
|
||||
// 书架展示偏好(对应 legado 的书架设置)
|
||||
ShelfLayout string `gorm:"type:varchar(16)" json:"shelf_layout"` // grid / list / compact
|
||||
ShelfGridColumns int `json:"shelf_grid_columns"` // 0 表示自适应
|
||||
ShelfSort string `gorm:"type:varchar(16)" json:"shelf_sort"`
|
||||
ShelfShowUnread bool `json:"shelf_show_unread"`
|
||||
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 书架分组(对应 legado 的 BookGroup)。
|
||||
//
|
||||
// 与影视模块的媒体库标签(LibraryTagSet)同构:组名 → 成员 ID 列表,一个成员只归一个组。
|
||||
// 区别只是归属对象从媒体库换成了书籍,因此前端可以照搬那套标签栏 / 管理弹窗的交互。
|
||||
|
||||
const (
|
||||
// MaxBookGroups 是单个用户可创建的分组数量上限,避免恶意写入过大的 JSON。
|
||||
MaxBookGroups = 50
|
||||
// MaxBookGroupNameLen 是单个分组名的最大字符长度(按 rune 计数)。
|
||||
MaxBookGroupNameLen = 24
|
||||
)
|
||||
|
||||
// BookGroupSet 是一个书架分组:组名 + 组内书籍 ID(顺序即组内展示顺序)。
|
||||
type BookGroupSet struct {
|
||||
Name string `json:"name"`
|
||||
BookIDs []string `json:"book_ids"`
|
||||
}
|
||||
|
||||
// ReaderBookGroups 是某个用户的书架分组集合,每个用户一条记录。
|
||||
//
|
||||
// 分组是「整份替换」语义(前端一次 PUT 提交完整分组列表),所以直接存成一份 JSON;
|
||||
// 与阅读器偏好(ReaderProfile)分表存放,两条写入路径互不干扰:
|
||||
// 先建分组、后调偏好时不会因为整行 Save 把对方的列写成零值。
|
||||
type ReaderBookGroups struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
|
||||
// Groups 是 []BookGroupSet 的 JSON 文本;空字符串表示没有分组。
|
||||
Groups string `gorm:"type:text" json:"-"`
|
||||
}
|
||||
|
||||
// DecodeBookGroups 解析 Groups 字段,忽略损坏的数据。
|
||||
func (r *ReaderBookGroups) DecodeBookGroups() []BookGroupSet {
|
||||
if r == nil || strings.TrimSpace(r.Groups) == "" {
|
||||
return nil
|
||||
}
|
||||
var groups []BookGroupSet
|
||||
if err := json.Unmarshal([]byte(r.Groups), &groups); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NormalizeBookGroups(groups)
|
||||
}
|
||||
|
||||
// EncodeBookGroups 把分组集合序列化为可写入 Groups 字段的 JSON 文本。
|
||||
// 空集合序列化为空字符串,便于用零值表达「没有分组」。
|
||||
func EncodeBookGroups(groups []BookGroupSet) (string, error) {
|
||||
if len(groups) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// NormalizeBookGroups 清洗分组集合:去掉空名分组、合并重名分组、去掉组内重复的
|
||||
// 书籍 ID,并保持传入顺序。不在这里做「一本书只归一个组」的收敛——那需要知道
|
||||
// 书籍是否存在,属于服务层(SetBookGroups)的职责。
|
||||
func NormalizeBookGroups(groups []BookGroupSet) []BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]BookGroupSet, 0, len(groups))
|
||||
indexByName := make(map[string]int, len(groups))
|
||||
for _, group := range groups {
|
||||
name := TruncateBookGroupName(group.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) >= MaxBookGroups {
|
||||
break
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
pos, exists := indexByName[key]
|
||||
if !exists {
|
||||
out = append(out, BookGroupSet{Name: name, BookIDs: []string{}})
|
||||
pos = len(out) - 1
|
||||
indexByName[key] = pos
|
||||
}
|
||||
seen := make(map[string]struct{}, len(out[pos].BookIDs))
|
||||
for _, id := range out[pos].BookIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range group.BookIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out[pos].BookIDs = append(out[pos].BookIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].BookIDs == nil {
|
||||
out[i].BookIDs = []string{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateBookGroupName 去掉首尾空白并按 rune 截断到长度上限。
|
||||
func TruncateBookGroupName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxBookGroupNameLen {
|
||||
runes = runes[:MaxBookGroupNameLen]
|
||||
}
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 书架分组的清洗与编解码回归测试(与 library_tag_test.go 同构)。
|
||||
|
||||
func TestNormalizeBookGroupsMergesDuplicatesAndTrims(t *testing.T) {
|
||||
groups := []BookGroupSet{
|
||||
{Name: " 科幻 ", BookIDs: []string{"a", "b"}},
|
||||
{Name: "科幻", BookIDs: []string{"b", "c"}},
|
||||
{Name: " ", BookIDs: []string{"d"}},
|
||||
{Name: "在读", BookIDs: []string{"", "e", "e"}},
|
||||
}
|
||||
got := NormalizeBookGroups(groups)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("NormalizeBookGroups len = %d, want 2 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "科幻" || strings.Join(got[0].BookIDs, ",") != "a,b,c" {
|
||||
t.Fatalf("首个分组 = %#v,期望 科幻[a,b,c]", got[0])
|
||||
}
|
||||
if got[1].Name != "在读" || strings.Join(got[1].BookIDs, ",") != "e" {
|
||||
t.Fatalf("第二个分组 = %#v,期望 在读[e]", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBookGroupsCapsCountAndNameLength(t *testing.T) {
|
||||
long := strings.Repeat("长", MaxBookGroupNameLen+10)
|
||||
got := NormalizeBookGroups([]BookGroupSet{{Name: long}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if runes := []rune(got[0].Name); len(runes) != MaxBookGroupNameLen {
|
||||
t.Fatalf("分组名长度 = %d,期望 %d", len(runes), MaxBookGroupNameLen)
|
||||
}
|
||||
|
||||
many := make([]BookGroupSet, 0, MaxBookGroups+5)
|
||||
for i := 0; i < MaxBookGroups+5; i++ {
|
||||
many = append(many, BookGroupSet{Name: string(rune('a' + i))})
|
||||
}
|
||||
if capped := NormalizeBookGroups(many); len(capped) != MaxBookGroups {
|
||||
t.Fatalf("capped len = %d, want %d", len(capped), MaxBookGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBookGroupsKeepsEmptyGroups(t *testing.T) {
|
||||
got := NormalizeBookGroups([]BookGroupSet{{Name: "空组"}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
// 空分组的 BookIDs 应该是空数组而不是 nil,前端直接遍历时不会炸
|
||||
if got[0].BookIDs == nil || len(got[0].BookIDs) != 0 {
|
||||
t.Fatalf("BookIDs = %#v,期望空数组", got[0].BookIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeBookGroupsRoundTrip(t *testing.T) {
|
||||
if encoded, err := EncodeBookGroups(nil); err != nil || encoded != "" {
|
||||
t.Fatalf("EncodeBookGroups(nil) = %q, %v; want \"\", nil", encoded, err)
|
||||
}
|
||||
|
||||
raw, err := EncodeBookGroups([]BookGroupSet{{Name: "科幻", BookIDs: []string{"b-1"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeBookGroups: %v", err)
|
||||
}
|
||||
row := &ReaderBookGroups{Groups: raw}
|
||||
decoded := row.DecodeBookGroups()
|
||||
if len(decoded) != 1 || decoded[0].Name != "科幻" || decoded[0].BookIDs[0] != "b-1" {
|
||||
t.Fatalf("DecodeBookGroups = %#v", decoded)
|
||||
}
|
||||
|
||||
row.Groups = "{not json"
|
||||
if decoded := row.DecodeBookGroups(); decoded != nil {
|
||||
t.Fatalf("损坏数据应返回 nil,实际 %#v", decoded)
|
||||
}
|
||||
|
||||
// nil 接收者不应 panic(仓库层查不到记录时会返回 nil)
|
||||
var nilRow *ReaderBookGroups
|
||||
if decoded := nilRow.DecodeBookGroups(); decoded != nil {
|
||||
t.Fatalf("nil 接收者应返回 nil,实际 %#v", decoded)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user