mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 00:06:37 +08:00
407 lines
16 KiB
Go
407 lines
16 KiB
Go
// Package repository — 阅读子系统仓储(书源 / 书架 / 章节 / 替换规则)。
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
// ReaderRepository 阅读子系统 GORM 封装。
|
|
type ReaderRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// ListSources 书源列表(按 customOrder 排序)。
|
|
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
|
|
var out []model.ReaderBookSource
|
|
err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// GetSource 按 ID 取书源。
|
|
func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.ReaderBookSource, error) {
|
|
var out model.ReaderBookSource
|
|
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// GetSourceByURL 按书源 URL 取书源(导入去重用)。
|
|
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
|
|
var out model.ReaderBookSource
|
|
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// CreateSource 新增书源。
|
|
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
|
|
return r.db.WithContext(ctx).Create(src).Error
|
|
}
|
|
|
|
// UpdateSource 更新书源字段。
|
|
func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBookSource) error {
|
|
return r.db.WithContext(ctx).Save(src).Error
|
|
}
|
|
|
|
// DeleteSource 删除书源(连带清理其会话状态)。
|
|
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
src := &model.ReaderBookSource{}
|
|
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
|
|
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
|
|
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
|
|
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
|
|
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
|
// cookie / 登录态从此再也存不进去。这里必须硬删。
|
|
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
|
|
})
|
|
}
|
|
|
|
// GetSourceState 取书源会话状态;不存在返回 (nil, nil)。
|
|
func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) (*model.ReaderSourceState, error) {
|
|
var out model.ReaderSourceState
|
|
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
|
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
|
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
|
|
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
|
|
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
|
|
var existing model.ReaderSourceState
|
|
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return r.db.WithContext(ctx).Create(st).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st.ID = existing.ID
|
|
st.CreatedAt = existing.CreatedAt
|
|
if existing.DeletedAt.Valid {
|
|
// 历史行被软删过:连 deleted_at 一起写回,把它复活
|
|
st.DeletedAt = gorm.DeletedAt{}
|
|
return r.db.WithContext(ctx).Unscoped().Save(st).Error
|
|
}
|
|
return r.db.WithContext(ctx).Save(st).Error
|
|
}
|
|
|
|
// ListBooks 用户书架(按 order 排序)。
|
|
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
|
|
var out []model.ReaderBook
|
|
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` ASC, updated_at DESC").Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// GetBook 按 ID 取书。
|
|
func (r *ReaderRepository) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
|
|
var out model.ReaderBook
|
|
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// FindBookByURL 按用户 + 书源 + 书 URL 查书(加书架去重)。
|
|
func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bookURL string) (*model.ReaderBook, error) {
|
|
var out model.ReaderBook
|
|
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND origin = ? AND book_url = ?", userID, origin, bookURL).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。
|
|
// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的
|
|
// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。
|
|
func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) {
|
|
var out []model.ReaderBook
|
|
err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// CreateBook / UpdateBook / DeleteBook。
|
|
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
|
|
return r.db.WithContext(ctx).Create(b).Error
|
|
}
|
|
|
|
func (r *ReaderRepository) UpdateBook(ctx context.Context, b *model.ReaderBook) error {
|
|
return r.db.WithContext(ctx).Save(b).Error
|
|
}
|
|
|
|
func (r *ReaderRepository) DeleteBook(ctx context.Context, userID, id string) error {
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Delete(&model.ReaderBook{}, "id = ? AND user_id = ?", id, userID).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Delete(&model.ReaderChapter{}, "book_id = ?", id).Error
|
|
})
|
|
}
|
|
|
|
// ReplaceChapters 覆盖式刷新章节列表。
|
|
func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, chapters []model.ReaderChapter) error {
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// 章节是纯缓存(软删会留下行,撞上 (book_id, index) 唯一索引),这里物理删除
|
|
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", bookID).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(chapters) == 0 {
|
|
return nil
|
|
}
|
|
return tx.CreateInBatches(chapters, 500).Error
|
|
})
|
|
}
|
|
|
|
// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。
|
|
// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。
|
|
func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error {
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Save(book).Error
|
|
})
|
|
}
|
|
|
|
// ListChapters 按序取章节。
|
|
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
|
|
var out []model.ReaderChapter
|
|
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Order("`index` ASC").Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// ─── 正文持久缓存索引 ──────────────────────────────────────────────────────
|
|
|
|
// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。
|
|
func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
|
|
var out model.ReaderContentCache
|
|
err := r.db.WithContext(ctx).
|
|
Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType).
|
|
First(&out).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。
|
|
// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。
|
|
func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
|
|
q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey)
|
|
if contentType != "" {
|
|
q = q.Where("content_type = ?", contentType)
|
|
}
|
|
var out model.ReaderContentCache
|
|
if err := q.Order("last_access_at DESC").First(&out).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。
|
|
func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error {
|
|
existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType)
|
|
if err == nil && existing != nil {
|
|
existing.ChapterIdentity = row.ChapterIdentity
|
|
existing.ChapterIndex = row.ChapterIndex
|
|
existing.SourceHash = row.SourceHash
|
|
existing.FormatVersion = row.FormatVersion
|
|
existing.SizeBytes = row.SizeBytes
|
|
existing.AssetCount = row.AssetCount
|
|
existing.ExpiresAt = row.ExpiresAt
|
|
existing.LastAccessAt = row.LastAccessAt
|
|
return r.db.WithContext(ctx).Save(existing).Error
|
|
}
|
|
return r.db.WithContext(ctx).Create(row).Error
|
|
}
|
|
|
|
// TouchContentCache 记录一次命中(更新命中时间与次数)。
|
|
func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error {
|
|
return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error
|
|
}
|
|
|
|
// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。
|
|
func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
|
|
var out []model.ReaderContentCache
|
|
err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。
|
|
func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
|
|
rows, err := r.ListContentCacheByBook(ctx, bookKey)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。
|
|
func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error {
|
|
return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error
|
|
}
|
|
|
|
// ListContentCacheExpired 按 TTL 取过期条目。
|
|
func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) {
|
|
var out []model.ReaderContentCache
|
|
err := r.db.WithContext(ctx).
|
|
Where("expires_at > 0 AND expires_at < ?", now).
|
|
Order("last_access_at ASC").Limit(limit).Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。
|
|
func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) {
|
|
var row struct {
|
|
Count int64
|
|
Bytes int64
|
|
}
|
|
err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
|
|
Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error
|
|
return row.Count, row.Bytes, err
|
|
}
|
|
|
|
// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。
|
|
func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) {
|
|
var out []model.ReaderContentCache
|
|
err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。
|
|
// 没有目录缓存的书籍不会出现在返回结果里。
|
|
func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, error) {
|
|
out := make(map[string]int, len(bookIDs))
|
|
if len(bookIDs) == 0 {
|
|
return out, nil
|
|
}
|
|
var rows []struct {
|
|
BookID string
|
|
Total int
|
|
}
|
|
err := r.db.WithContext(ctx).
|
|
Model(&model.ReaderChapter{}).
|
|
Select("book_id, COUNT(*) AS total").
|
|
Where("book_id IN ?", bookIDs).
|
|
Group("book_id").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
out[row.BookID] = row.Total
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetChapter 取指定章节。
|
|
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
|
|
var out model.ReaderChapter
|
|
if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// GetReaderProfile 取用户阅读器偏好;没有记录时返回 (nil, nil),
|
|
// 由调用方决定是否用前端上送的当前值播种。
|
|
func (r *ReaderRepository) GetReaderProfile(ctx context.Context, userID string) (*model.ReaderProfile, error) {
|
|
var out model.ReaderProfile
|
|
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// SaveReaderProfile 覆盖保存用户阅读器偏好(不存在则新建)。
|
|
func (r *ReaderRepository) SaveReaderProfile(ctx context.Context, p *model.ReaderProfile) error {
|
|
var existing model.ReaderProfile
|
|
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", p.UserID).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return r.db.WithContext(ctx).Create(p).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.ID = existing.ID
|
|
p.CreatedAt = existing.CreatedAt
|
|
return r.db.WithContext(ctx).Save(p).Error
|
|
}
|
|
|
|
// GetBookGroups 取用户的书架分组;没有记录时返回 (nil, nil)。
|
|
func (r *ReaderRepository) GetBookGroups(ctx context.Context, userID string) (*model.ReaderBookGroups, error) {
|
|
var out model.ReaderBookGroups
|
|
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// SaveBookGroups 覆盖保存用户的书架分组(不存在则新建)。
|
|
func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.ReaderBookGroups) error {
|
|
var existing model.ReaderBookGroups
|
|
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", row.UserID).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return r.db.WithContext(ctx).Create(row).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
row.ID = existing.ID
|
|
row.CreatedAt = existing.CreatedAt
|
|
return r.db.WithContext(ctx).Save(row).Error
|
|
}
|
|
|
|
// ListReplaceRules 用户替换规则(按 order 排序)。
|
|
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
|
|
var out []model.ReaderReplaceRule
|
|
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` DESC, created_at ASC").Find(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// CreateReplaceRule / UpdateReplaceRule / DeleteReplaceRule。
|
|
func (r *ReaderRepository) CreateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
|
|
return r.db.WithContext(ctx).Create(rule).Error
|
|
}
|
|
|
|
func (r *ReaderRepository) UpdateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
|
|
return r.db.WithContext(ctx).Save(rule).Error
|
|
}
|
|
|
|
func (r *ReaderRepository) DeleteReplaceRule(ctx context.Context, userID, id string) error {
|
|
return r.db.WithContext(ctx).Delete(&model.ReaderReplaceRule{}, "id = ? AND user_id = ?", id, userID).Error
|
|
}
|