mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 04:26:36 +08:00
优化,排查项目问题
This commit is contained in:
@@ -10,17 +10,17 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// ApiConfigRepository persists model.ApiConfig records.
|
||||
// ApiConfigRepository persists model.APIConfig records.
|
||||
type ApiConfigRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new API config record.
|
||||
func (r *ApiConfigRepository) Create(ctx context.Context, c *model.ApiConfig) error {
|
||||
func (r *ApiConfigRepository) Create(ctx context.Context, c *model.APIConfig) error {
|
||||
return r.db.WithContext(ctx).Create(c).Error
|
||||
}
|
||||
|
||||
// FindByProvider returns the API config for a provider, or (nil, nil).
|
||||
func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
|
||||
var c model.ApiConfig
|
||||
func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
|
||||
var c model.APIConfig
|
||||
err := r.db.WithContext(ctx).Where("provider = ?", provider).First(&c).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@@ -32,27 +32,40 @@ func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider strin
|
||||
}
|
||||
|
||||
// List returns all API configs.
|
||||
func (r *ApiConfigRepository) List(ctx context.Context) ([]model.ApiConfig, error) {
|
||||
var rows []model.ApiConfig
|
||||
func (r *ApiConfigRepository) List(ctx context.Context) ([]model.APIConfig, error) {
|
||||
var rows []model.APIConfig
|
||||
err := r.db.WithContext(ctx).Order("provider asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Upsert creates or updates an API config.
|
||||
func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Where("provider = ?", c.Provider).
|
||||
Assign(model.ApiConfig{
|
||||
Base: model.Base{UpdatedAt: time.Now()},
|
||||
APIKey: c.APIKey,
|
||||
BaseURL: c.BaseURL,
|
||||
Extra: c.Extra,
|
||||
Enabled: c.Enabled,
|
||||
}).FirstOrCreate(c).Error
|
||||
// 显式 map 更新:Assign(struct) 会跳过零值字段,导致 Enabled=false、
|
||||
// 清空 BaseURL/Extra 等撤销操作静默失效。
|
||||
func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.APIConfig) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.APIConfig
|
||||
err := tx.Where("provider = ?", c.Provider).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tx.Create(c).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ID = existing.ID
|
||||
c.CreatedAt = existing.CreatedAt
|
||||
return tx.Model(&existing).Updates(map[string]any{
|
||||
"api_key": c.APIKey,
|
||||
"base_url": c.BaseURL,
|
||||
"extra": c.Extra,
|
||||
"enabled": c.Enabled,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Update updates an API config.
|
||||
func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
|
||||
func (r *ApiConfigRepository) Update(ctx context.Context, c *model.APIConfig) error {
|
||||
return r.db.WithContext(ctx).Model(&model.APIConfig{}).
|
||||
Where("provider = ?", c.Provider).Updates(map[string]any{
|
||||
"api_key": c.APIKey,
|
||||
"base_url": c.BaseURL,
|
||||
@@ -64,13 +77,13 @@ func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) er
|
||||
|
||||
// Delete 物理删除 API 配置。
|
||||
func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
|
||||
return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.APIConfig{}).Error
|
||||
}
|
||||
|
||||
// UpdateTestResult 更新测试结果。
|
||||
func (r *ApiConfigRepository) UpdateTestResult(ctx context.Context, provider, result string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
|
||||
return r.db.WithContext(ctx).Model(&model.APIConfig{}).
|
||||
Where("provider = ?", provider).Updates(map[string]any{
|
||||
"test_result": result,
|
||||
"last_tested_at": &now,
|
||||
|
||||
@@ -22,45 +22,93 @@ import (
|
||||
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
|
||||
// 永远捞不到数据。
|
||||
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.upsertWithDB(ctx, r.db, m)
|
||||
var indexIDs []string
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
id, uerr := r.upsertWithDB(ctx, r.db, m)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
indexIDs = append(indexIDs[:0], id)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.indexByIDBestEffort(ctx, indexIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
|
||||
// 而不是每条一个隐式事务。任一条目落库失败不影响批内已成功的条目——
|
||||
// 事务回滚后由调用方退回逐条 Upsert 兜底。
|
||||
//
|
||||
// OpenSearch 索引同步(HTTP,4s 超时)必须在事务提交之后统一执行:放在
|
||||
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
|
||||
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
|
||||
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
indexIDs := make([]string, 0, len(items))
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
indexIDs = indexIDs[:0]
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, m := range items {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if err := r.upsertWithDB(ctx, tx, m); err != nil {
|
||||
id, err := r.upsertWithDB(ctx, tx, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id != "" {
|
||||
indexIDs = append(indexIDs, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) error {
|
||||
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.indexByIDBestEffort(ctx, indexIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
|
||||
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
|
||||
r.indexMediaBestEffort(ctx, *fresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
|
||||
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created {
|
||||
r.indexMediaBestEffort(ctx, *m)
|
||||
return nil
|
||||
return m.ID, nil
|
||||
}
|
||||
|
||||
updates := mediaUpsertUpdates(existing, *m)
|
||||
return r.applyMediaUpsertUpdates(ctx, db, m, existing, updates)
|
||||
if len(updates) == 0 {
|
||||
*m = existing
|
||||
return "", nil
|
||||
}
|
||||
if err := db.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
|
||||
*m = existing
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
|
||||
@@ -75,6 +123,9 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
|
||||
return *m, true, nil
|
||||
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
|
||||
return model.Media{}, false, createErr
|
||||
} else {
|
||||
// 并发插入竞态:重查已命中既有行,直接走更新分支。
|
||||
return existing, false, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -259,24 +310,6 @@ func setNonEmptyMediaString(updates map[string]any, key, current, next string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MediaRepository) applyMediaUpsertUpdates(ctx context.Context, db *gorm.DB, m *model.Media, existing model.Media, updates map[string]any) error {
|
||||
if len(updates) == 0 {
|
||||
*m = existing
|
||||
return nil
|
||||
}
|
||||
if err := db.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
|
||||
*m = existing
|
||||
if fresh, err := r.FindByID(ctx, existing.ID); err == nil && fresh != nil {
|
||||
*m = *fresh
|
||||
r.indexMediaBestEffort(ctx, *fresh)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setIfChanged[T comparable](updates map[string]any, key string, current, next T) {
|
||||
if current != next {
|
||||
updates[key] = next
|
||||
|
||||
@@ -105,11 +105,17 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
|
||||
var total int64
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{})
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
|
||||
// 需用 ILIKE 保持两端搜索行为一致。
|
||||
likeOp := "LIKE"
|
||||
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
|
||||
likeOp = "ILIKE"
|
||||
}
|
||||
terms := mediaSearchTerms(query)
|
||||
for _, term := range terms {
|
||||
like := "%" + escapeLike(term) + "%"
|
||||
q = q.Where(
|
||||
"(title LIKE ? ESCAPE '\\' OR original_name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' OR genres LIKE ? ESCAPE '\\')",
|
||||
"(title "+likeOp+" ? ESCAPE '\\' OR original_name "+likeOp+" ? ESCAPE '\\' OR path "+likeOp+" ? ESCAPE '\\' OR genres "+likeOp+" ? ESCAPE '\\')",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
@@ -120,7 +126,7 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
|
||||
prefix := escapeLike(query) + "%"
|
||||
exact := query
|
||||
q = q.Order(gorm.Expr(
|
||||
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title LIKE ? ESCAPE '\\' THEN 2 WHEN original_name LIKE ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
|
||||
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
|
||||
exact, exact, prefix, prefix,
|
||||
))
|
||||
} else {
|
||||
@@ -259,7 +265,9 @@ func (r *MediaRepository) searchIndexEnabled(ctx context.Context) bool {
|
||||
}
|
||||
r.searchIndexOnce.Do(func() {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
// 用 Background 探测:sync.Once 只执行一次,若借用调用方的
|
||||
// ctx 且恰好被取消,FTS 会被永久误判为不可用。
|
||||
err := r.db.WithContext(context.Background()).
|
||||
Raw(`SELECT COUNT(*) FROM sqlite_master WHERE name = 'media_search_fts'`).
|
||||
Scan(&count).Error
|
||||
r.searchIndexAvailable = err == nil && count > 0
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -44,10 +45,43 @@ func (r *PermissionRepository) Update(ctx context.Context, userID string, update
|
||||
}
|
||||
|
||||
// Upsert creates or updates a permission record.
|
||||
// 显式 map 更新:Assign(struct) 会被 GORM 跳过零值字段,导致权限
|
||||
// "撤销"(false)保存后静默失效且无法重置。
|
||||
func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermission) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Where("user_id = ?", p.UserID).
|
||||
Assign(*p).FirstOrCreate(p).Error
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.UserPermission
|
||||
err := tx.Where("user_id = ?", p.UserID).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tx.Create(p).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = existing.ID
|
||||
p.CreatedAt = existing.CreatedAt
|
||||
return tx.Model(&existing).Updates(map[string]any{
|
||||
"can_view_dashboard": p.CanViewDashboard,
|
||||
"can_play_media": p.CanPlayMedia,
|
||||
"can_cast": p.CanCast,
|
||||
"can_external_player": p.CanExternalPlayer,
|
||||
"can_favorite": p.CanFavorite,
|
||||
"can_view_history": p.CanViewHistory,
|
||||
"can_edit_media": p.CanEditMedia,
|
||||
"can_rescrape": p.CanRescrape,
|
||||
"can_use_ai": p.CanUseAI,
|
||||
"can_capture_frames": p.CanCaptureFrames,
|
||||
"can_manage_downloads": p.CanManageDownloads,
|
||||
"can_manage_subscriptions": p.CanManageSubscriptions,
|
||||
"can_manage_sites": p.CanManageSites,
|
||||
"can_use_ai_assistant": p.CanUseAIAssistant,
|
||||
"can_manage_users": p.CanManageUsers,
|
||||
"can_manage_files": p.CanManageFiles,
|
||||
"can_manage_strm": p.CanManageStrm,
|
||||
"can_access_settings": p.CanAccessSettings,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,40 @@ func (r *ScrapeTaskRepository) FindActiveByMediaID(ctx context.Context, mediaID
|
||||
return &t, err
|
||||
}
|
||||
|
||||
// FindActiveByMediaIDs 批量查询仍处于 pending/running 的任务媒体 ID 集合,
|
||||
// 供整库入队时去重(防止同一媒体被重复入队并被并发双刮)。
|
||||
func (r *ScrapeTaskRepository) FindActiveByMediaIDs(ctx context.Context, mediaIDs []string) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(mediaIDs))
|
||||
if len(mediaIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []model.ScrapeTask
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("media_id").
|
||||
Where("media_id IN ? AND status IN ?", mediaIDs, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[r.MediaID] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResetRunningToPending 启动自愈:进程中断遗留的 running 任务重置为 pending,
|
||||
// 否则任务永久卡死(ClaimPending 只认 pending,重试按钮也拒绝 running)。
|
||||
func (r *ScrapeTaskRepository) ResetRunningToPending(ctx context.Context) (int64, error) {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("status = ?", model.ScrapeTaskRunning).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskPending,
|
||||
"error": "服务重启,任务已重置",
|
||||
"started_at": nil,
|
||||
})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.ScrapeTask, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
@@ -302,6 +302,39 @@ func (r *StrmDownloadTaskRepository) Update(ctx context.Context, t *model.StrmDo
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateIfRunning 仅当任务在 DB 中仍为 running 时写入给定字段。
|
||||
// 返回 false 表示任务已被外部改变状态(如用户取消),收尾不得覆盖。
|
||||
func (r *StrmDownloadTaskRepository) UpdateIfRunning(ctx context.Context, id string, updates map[string]any) (bool, error) {
|
||||
var ok bool
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
updates["updated_at"] = time.Now()
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
|
||||
Where("id = ? AND status = ?", id, model.StrmTaskRunning).Updates(updates)
|
||||
ok = res.RowsAffected > 0
|
||||
return res.Error
|
||||
})
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// ResetRunningToPending 启动自愈:进程中断遗留的 running 任务全部重置为
|
||||
// pending(清空退避时间以便立即可被认领),否则任务永久卡死且会阻塞
|
||||
// 该文件的重复下载。
|
||||
func (r *StrmDownloadTaskRepository) ResetRunningToPending(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
|
||||
Where("status = ?", model.StrmTaskRunning).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "服务重启,任务已重置",
|
||||
"started_at": nil,
|
||||
})
|
||||
n = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
|
||||
@@ -620,6 +653,36 @@ func (r *StrmUploadTaskRepository) Update(ctx context.Context, t *model.StrmUplo
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateIfRunning 仅当任务在 DB 中仍为 running 时写入给定字段。
|
||||
func (r *StrmUploadTaskRepository) UpdateIfRunning(ctx context.Context, id string, updates map[string]any) (bool, error) {
|
||||
var ok bool
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
updates["updated_at"] = time.Now()
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("id = ? AND status = ?", id, model.StrmTaskRunning).Updates(updates)
|
||||
ok = res.RowsAffected > 0
|
||||
return res.Error
|
||||
})
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// ResetRunningToPending 启动自愈:进程中断遗留的 running 任务全部重置为 pending。
|
||||
func (r *StrmUploadTaskRepository) ResetRunningToPending(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("status = ?", model.StrmTaskRunning).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "服务重启,任务已重置",
|
||||
"started_at": nil,
|
||||
})
|
||||
n = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
|
||||
|
||||
Reference in New Issue
Block a user