mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
@@ -97,6 +97,63 @@ func TestEnforceTelegramBindingOneToOneCleansDuplicatesAndAddsIndex(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSubscriptionIdentityUniquenessArchivesDuplicatesAndAddsIndex(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Subscription{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdAt := time.Now().Add(-time.Hour)
|
||||
rows := []model.Subscription{
|
||||
{UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50},
|
||||
{UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50},
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].CreatedAt = createdAt.Add(time.Duration(i) * time.Minute)
|
||||
if err := db.Select("*").Omit("DeletedAt").Create(&rows[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureSubscriptionIdentityUniqueness(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var active []model.Subscription
|
||||
if err := db.Where("archived_at IS NULL").Order("created_at asc").Find(&active).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(active) != 1 || active[0].ID != rows[0].ID {
|
||||
t.Fatalf("active subscriptions = %#v, want earliest row only", active)
|
||||
}
|
||||
var archived model.Subscription
|
||||
if err := db.First(&archived, "id = ?", rows[1].ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archived.ArchivedAt == nil || archived.ArchiveReason != duplicateSubscriptionMigrationReason {
|
||||
t.Fatalf("duplicate was not archived by migration: %#v", archived)
|
||||
}
|
||||
|
||||
duplicate := rows[0]
|
||||
duplicate.ID = ""
|
||||
duplicate.CreatedAt = time.Time{}
|
||||
duplicate.UpdatedAt = time.Time{}
|
||||
model.RefreshSubscriptionIdentity(&duplicate)
|
||||
if err := db.Select("*").Omit("DeletedAt").Create(&duplicate).Error; err == nil {
|
||||
t.Fatal("expected active identity index to reject a duplicate rule")
|
||||
}
|
||||
different := rows[0]
|
||||
different.ID = ""
|
||||
different.CreatedAt = time.Time{}
|
||||
different.UpdatedAt = time.Time{}
|
||||
different.Resolution = "2160p"
|
||||
model.RefreshSubscriptionIdentity(&different)
|
||||
if err := db.Select("*").Omit("DeletedAt").Create(&different).Error; err != nil {
|
||||
t.Fatalf("different rule should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,9 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
if err := enforceTelegramBindingOneToOne(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSubscriptionIdentityUniqueness(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensurePerformanceIndexes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
const duplicateSubscriptionMigrationReason = "迁移合并重复订阅规则"
|
||||
|
||||
func ensureSubscriptionIdentityUniqueness(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable(&model.Subscription{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var rows []model.Subscription
|
||||
if err := tx.Unscoped().Order("created_at asc, id asc").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]string)
|
||||
for i := range rows {
|
||||
row := &rows[i]
|
||||
key := model.RefreshSubscriptionIdentity(row)
|
||||
updates := map[string]any{"identity_key": key}
|
||||
if !row.DeletedAt.Valid && row.ArchivedAt == nil {
|
||||
activeKey := row.UserID + "\x00" + key
|
||||
if _, duplicate := seen[activeKey]; duplicate {
|
||||
archivedAt := row.UpdatedAt
|
||||
if archivedAt.IsZero() {
|
||||
archivedAt = row.CreatedAt
|
||||
}
|
||||
if archivedAt.IsZero() {
|
||||
archivedAt = time.Now()
|
||||
}
|
||||
updates["enabled"] = false
|
||||
updates["archived_at"] = &archivedAt
|
||||
updates["archive_reason"] = duplicateSubscriptionMigrationReason
|
||||
} else {
|
||||
seen[activeKey] = row.ID
|
||||
}
|
||||
}
|
||||
if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_user_identity_active
|
||||
ON subscriptions(user_id, identity_key)
|
||||
WHERE deleted_at IS NULL AND archived_at IS NULL AND identity_key <> ''
|
||||
`).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func writeSubscriptionConflict(c *gin.Context, err error) bool {
|
||||
if !errors.Is(err, service.ErrSubscriptionAlreadyExists) {
|
||||
return false
|
||||
}
|
||||
body := gin.H{"error": "subscription already exists"}
|
||||
if existingID := service.SubscriptionAlreadyExistsID(err); existingID != "" {
|
||||
body["existing_id"] = existingID
|
||||
}
|
||||
c.JSON(http.StatusConflict, body)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestWriteSubscriptionConflictReturns409WithExistingID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
err := &service.SubscriptionAlreadyExistsError{ExistingID: "existing-sub"}
|
||||
if !writeSubscriptionConflict(c, err) {
|
||||
t.Fatal("expected conflict to be handled")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["existing_id"] != "existing-sub" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
}
|
||||
@@ -58,14 +58,14 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Model(&model.Subscription{}).
|
||||
Where("id = ?", c.Param("id")).
|
||||
Updates(updates).Error; err != nil {
|
||||
if err := svc.Subscription.Update(c.Request.Context(), c.Param("id"), updates); err != nil {
|
||||
logSubscriptionWarn(svc, "subscription update failed",
|
||||
zap.String("user_id", subscriptionRequestUserID(c)),
|
||||
zap.String("subscription_id", c.Param("id")),
|
||||
zap.Error(err))
|
||||
if writeSubscriptionConflict(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
zap.String("feed_kind", subscriptionFeedKind(req.FeedURL)),
|
||||
zap.Bool("enabled", enabled),
|
||||
zap.Error(err))
|
||||
if writeSubscriptionConflict(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -199,6 +202,9 @@ func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
zap.String("user_id", subscriptionRequestUserID(c)),
|
||||
zap.String("subscription_id", c.Param("id")),
|
||||
zap.Error(err))
|
||||
if writeSubscriptionConflict(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type DownloadTask struct {
|
||||
type Subscription struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
IdentityKey string `gorm:"size:64" json:"-"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
|
||||
Filter string `gorm:"size:512" json:"filter"`
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type subscriptionIdentityPayload struct {
|
||||
Name string `json:"name"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
Filter string `json:"filter"`
|
||||
MediaType string `json:"media_type"`
|
||||
MediaCategory string `json:"media_category"`
|
||||
SavePath string `json:"save_path"`
|
||||
SearchMode string `json:"search_mode"`
|
||||
IMDBID string `json:"imdb_id"`
|
||||
Resolution string `json:"resolution"`
|
||||
Quality string `json:"quality"`
|
||||
Effects string `json:"effects"`
|
||||
ReleaseGroups string `json:"release_groups"`
|
||||
ExcludeWords string `json:"exclude_words"`
|
||||
MinSeeders int `json:"min_seeders"`
|
||||
MaxSeeders int `json:"max_seeders"`
|
||||
MinSizeGB float64 `json:"min_size_gb"`
|
||||
MaxSizeGB float64 `json:"max_size_gb"`
|
||||
FreeOnly bool `json:"free_only"`
|
||||
WashEnabled bool `json:"wash_enabled"`
|
||||
WashPriority string `json:"wash_priority"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// SubscriptionIdentityKey identifies one functional subscription rule. It
|
||||
// intentionally excludes display metadata and runtime/archive state because
|
||||
// those fields are backfilled or changed automatically after creation.
|
||||
func SubscriptionIdentityKey(sub *Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
payload := subscriptionIdentityPayload{
|
||||
Name: subscriptionIdentityFold(sub.Name),
|
||||
FeedURL: strings.TrimSpace(sub.FeedURL),
|
||||
Filter: strings.TrimSpace(sub.Filter),
|
||||
MediaType: subscriptionIdentityFold(sub.MediaType),
|
||||
MediaCategory: subscriptionIdentityFold(sub.MediaCategory),
|
||||
SavePath: strings.TrimSpace(sub.SavePath),
|
||||
SearchMode: subscriptionIdentityFold(sub.SearchMode),
|
||||
IMDBID: subscriptionIdentityFold(sub.IMDBID),
|
||||
Resolution: subscriptionIdentityFold(sub.Resolution),
|
||||
Quality: subscriptionIdentityFold(sub.Quality),
|
||||
Effects: subscriptionIdentityList(sub.Effects),
|
||||
ReleaseGroups: subscriptionIdentityList(sub.ReleaseGroups),
|
||||
ExcludeWords: subscriptionIdentityList(sub.ExcludeWords),
|
||||
MinSeeders: sub.MinSeeders,
|
||||
MaxSeeders: sub.MaxSeeders,
|
||||
MinSizeGB: sub.MinSizeGB,
|
||||
MaxSizeGB: sub.MaxSizeGB,
|
||||
FreeOnly: sub.FreeOnly,
|
||||
WashEnabled: sub.WashEnabled,
|
||||
WashPriority: subscriptionIdentityFold(sub.WashPriority),
|
||||
Priority: sub.Priority,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func RefreshSubscriptionIdentity(sub *Subscription) string {
|
||||
key := SubscriptionIdentityKey(sub)
|
||||
if sub != nil {
|
||||
sub.IdentityKey = key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func subscriptionIdentityFold(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func subscriptionIdentityList(value string) string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = subscriptionIdentityFold(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubscriptionIdentityKeyNormalizesEquivalentRules(t *testing.T) {
|
||||
base := Subscription{
|
||||
Name: " Example Show ",
|
||||
FeedURL: "site-search://search?keyword=Example",
|
||||
Filter: " Example.*Show ",
|
||||
MediaType: "TV",
|
||||
MediaCategory: " 欧美剧 ",
|
||||
SearchMode: "KEYWORD",
|
||||
Resolution: "1080P",
|
||||
Effects: " HDR, Atmos ",
|
||||
ExcludeWords: " CAM, TS ",
|
||||
WashPriority: "Balanced",
|
||||
Priority: 50,
|
||||
}
|
||||
equivalent := base
|
||||
equivalent.Name = "example show"
|
||||
equivalent.MediaType = "tv"
|
||||
equivalent.MediaCategory = "欧美剧"
|
||||
equivalent.SearchMode = "keyword"
|
||||
equivalent.Resolution = "1080p"
|
||||
equivalent.Effects = "hdr,atmos"
|
||||
equivalent.ExcludeWords = "cam,ts"
|
||||
equivalent.WashPriority = "balanced"
|
||||
if got, want := SubscriptionIdentityKey(&equivalent), SubscriptionIdentityKey(&base); got != want {
|
||||
t.Fatalf("equivalent keys differ: %s != %s", got, want)
|
||||
}
|
||||
|
||||
differentRule := base
|
||||
differentRule.Resolution = "2160p"
|
||||
if SubscriptionIdentityKey(&differentRule) == SubscriptionIdentityKey(&base) {
|
||||
t.Fatal("different resolution should produce a different identity")
|
||||
}
|
||||
|
||||
displayOnly := base
|
||||
displayOnly.PosterURL = "https://example.test/poster.jpg"
|
||||
displayOnly.Overview = "new overview"
|
||||
displayOnly.TotalEpisodes = 24
|
||||
if SubscriptionIdentityKey(&displayOnly) != SubscriptionIdentityKey(&base) {
|
||||
t.Fatal("display/runtime metadata should not change identity")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -17,6 +18,28 @@ func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscripti
|
||||
return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error
|
||||
}
|
||||
|
||||
// FindActiveByIdentity returns an unarchived, non-deleted rule with the same
|
||||
// per-user functional identity. excludeID is used while editing/restoring.
|
||||
func (r *SubscriptionRepository) FindActiveByIdentity(ctx context.Context, userID, identityKey, excludeID string) (*model.Subscription, error) {
|
||||
if identityKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
q := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND identity_key = ? AND archived_at IS NULL", userID, identityKey)
|
||||
if excludeID != "" {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var sub model.Subscription
|
||||
err := q.First(&sub).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// List returns active subscription rules. Archived rows live in history and are
|
||||
// intentionally excluded from scheduler polling and the active management list.
|
||||
func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) {
|
||||
|
||||
@@ -98,8 +98,17 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio
|
||||
return errors.New("name and feed_url required")
|
||||
}
|
||||
normalizeSubscriptionDefaults(sub)
|
||||
model.RefreshSubscriptionIdentity(sub)
|
||||
if duplicate, err := s.subscriptionDuplicate(ctx, sub, ""); err != nil {
|
||||
return err
|
||||
} else if duplicate != nil {
|
||||
return newSubscriptionAlreadyExistsError(duplicate.ID)
|
||||
}
|
||||
enabled := sub.Enabled
|
||||
if err := s.repo.Subscription.Create(ctx, sub); err != nil {
|
||||
if duplicate, lookupErr := s.subscriptionDuplicate(ctx, sub, ""); lookupErr == nil && duplicate != nil {
|
||||
return newSubscriptionAlreadyExistsError(duplicate.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
|
||||
@@ -23,6 +23,18 @@ func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Su
|
||||
if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.Enabled = true
|
||||
sub.ArchivedAt = nil
|
||||
sub.ArchiveReason = ""
|
||||
sub.DeletedAt.Valid = false
|
||||
sub.TotalEpisodes = 0
|
||||
normalizeSubscriptionDefaults(&sub)
|
||||
model.RefreshSubscriptionIdentity(&sub)
|
||||
if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil {
|
||||
return nil, err
|
||||
} else if duplicate != nil {
|
||||
return nil, newSubscriptionAlreadyExistsError(duplicate.ID)
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Subscription{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
@@ -30,12 +42,16 @@ func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Su
|
||||
"archived_at": nil,
|
||||
"archive_reason": "",
|
||||
"deleted_at": nil,
|
||||
"identity_key": sub.IdentityKey,
|
||||
// 重置为 0:此前可能被 feed 低估并锁死(updateSubscriptionTotalEpisodes
|
||||
// 只增不减,resolveSubscriptionTotalEpisodes 见 >0 即不再回查元数据)。
|
||||
// 归零后下次 run 会从 TMDb/豆瓣等权威源重算真实总集数,避免恢复后
|
||||
// 因"误判已无缺集"而不再搜索资源。
|
||||
"total_episodes": 0,
|
||||
}).Error; err != nil {
|
||||
if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil {
|
||||
return nil, newSubscriptionAlreadyExistsError(duplicate.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if s.repo.Setting != nil {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
var ErrSubscriptionAlreadyExists = errors.New("subscription already exists")
|
||||
|
||||
type SubscriptionAlreadyExistsError struct {
|
||||
ExistingID string
|
||||
}
|
||||
|
||||
func (e *SubscriptionAlreadyExistsError) Error() string {
|
||||
if e == nil || e.ExistingID == "" {
|
||||
return ErrSubscriptionAlreadyExists.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", ErrSubscriptionAlreadyExists, e.ExistingID)
|
||||
}
|
||||
|
||||
func (e *SubscriptionAlreadyExistsError) Unwrap() error {
|
||||
return ErrSubscriptionAlreadyExists
|
||||
}
|
||||
|
||||
func newSubscriptionAlreadyExistsError(existingID string) error {
|
||||
return &SubscriptionAlreadyExistsError{ExistingID: existingID}
|
||||
}
|
||||
|
||||
func SubscriptionAlreadyExistsID(err error) string {
|
||||
var conflict *SubscriptionAlreadyExistsError
|
||||
if errors.As(err, &conflict) && conflict != nil {
|
||||
return conflict.ExistingID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) subscriptionDuplicate(ctx context.Context, sub *model.Subscription, excludeID string) (*model.Subscription, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.repo.Subscription.FindActiveByIdentity(ctx, sub.UserID, sub.IdentityKey, excludeID)
|
||||
}
|
||||
|
||||
// Update applies API patch fields while recomputing the functional identity.
|
||||
// The database partial unique index remains the final concurrency guard.
|
||||
func (s *SubscriptionService) Update(ctx context.Context, id string, updates map[string]any) error {
|
||||
if s == nil || s.repo == nil || s.repo.DB == nil {
|
||||
return errors.New("subscription service unavailable")
|
||||
}
|
||||
var sub model.Subscription
|
||||
if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(updates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &sub); err != nil {
|
||||
return err
|
||||
}
|
||||
if sub.Name == "" || sub.FeedURL == "" {
|
||||
return errors.New("name and feed_url required")
|
||||
}
|
||||
normalizeSubscriptionDefaults(&sub)
|
||||
model.RefreshSubscriptionIdentity(&sub)
|
||||
if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil {
|
||||
return err
|
||||
} else if duplicate != nil {
|
||||
return newSubscriptionAlreadyExistsError(duplicate.ID)
|
||||
}
|
||||
|
||||
updates["search_mode"] = sub.SearchMode
|
||||
updates["resolution"] = sub.Resolution
|
||||
updates["wash_priority"] = sub.WashPriority
|
||||
updates["priority"] = sub.Priority
|
||||
updates["identity_key"] = sub.IdentityKey
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).
|
||||
Where("id = ?", sub.ID).Updates(updates).Error; err != nil {
|
||||
if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil {
|
||||
return newSubscriptionAlreadyExistsError(duplicate.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/database"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func TestSubscriptionCreateRejectsConcurrentDuplicateRules(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = filepath.Join(t.TempDir(), "subscriptions.db")
|
||||
cfg.Database.WALMode = true
|
||||
cfg.Database.BusyTimeout = 5000
|
||||
cfg.Database.MaxOpenConns = 4
|
||||
db, err := database.Open(cfg, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
results <- svc.Create(t.Context(), &model.Subscription{
|
||||
UserID: "user-1",
|
||||
Name: "Example Show",
|
||||
FeedURL: "site-search://search?keyword=Example+Show",
|
||||
Filter: "Example Show",
|
||||
MediaType: "tv",
|
||||
Resolution: "1080p",
|
||||
Enabled: true,
|
||||
})
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var created, conflicts int
|
||||
for createErr := range results {
|
||||
switch {
|
||||
case createErr == nil:
|
||||
created++
|
||||
case errors.Is(createErr, ErrSubscriptionAlreadyExists):
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("unexpected create error: %v", createErr)
|
||||
}
|
||||
}
|
||||
if created != 1 || conflicts != 1 {
|
||||
t.Fatalf("created=%d conflicts=%d, want 1/1", created, conflicts)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Subscription{}).Where("archived_at IS NULL").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("active subscription count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionUpdateAndRestoreReturnExistingConflict(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Subscription{})
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
|
||||
first := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "1080p", Enabled: true}
|
||||
second := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "2160p", Enabled: true}
|
||||
if err := svc.Create(t.Context(), first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Create(t.Context(), second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Update(t.Context(), second.ID, map[string]any{"resolution": "1080p"}); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID {
|
||||
t.Fatalf("update error = %v, want conflict with %s", err, first.ID)
|
||||
}
|
||||
|
||||
archivedAt := time.Now()
|
||||
archived := *first
|
||||
archived.ID = ""
|
||||
archived.ArchivedAt = &archivedAt
|
||||
archived.Enabled = false
|
||||
if err := repos.Subscription.Create(t.Context(), &archived); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Restore(t.Context(), archived.ID); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID {
|
||||
t.Fatalf("restore error = %v, want conflict with %s", err, first.ID)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@ func (s *SubscriptionService) prepareSubscriptionForRun(ctx context.Context, sub
|
||||
}
|
||||
normalizeSubscriptionDefaults(sub)
|
||||
updates := map[string]any{}
|
||||
if s.fillSubscriptionRunMetadata(ctx, sub, updates); len(updates) > 0 && s.repo != nil && s.repo.DB != nil {
|
||||
s.fillSubscriptionRunMetadata(ctx, sub, updates)
|
||||
if identityKey := model.SubscriptionIdentityKey(sub); identityKey != sub.IdentityKey {
|
||||
sub.IdentityKey = identityKey
|
||||
updates["identity_key"] = identityKey
|
||||
}
|
||||
if len(updates) > 0 && s.repo != nil && s.repo.DB != nil {
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", sub.ID).Updates(updates).Error; err != nil && s.log != nil {
|
||||
s.log.Debug("subscription metadata prepare persist failed", zap.String("id", sub.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Plus, Save } from 'lucide-react'
|
||||
import { Loader2, Plus, Save } from 'lucide-react'
|
||||
|
||||
import type { SubscriptionFormValues } from './subscriptionFormModel'
|
||||
|
||||
interface SubscriptionFormProps {
|
||||
values: SubscriptionFormValues
|
||||
editing: boolean
|
||||
busy: boolean
|
||||
onSubmit: (event: FormEvent) => void
|
||||
onCancelEdit: () => void
|
||||
onChange: <K extends keyof SubscriptionFormValues>(key: K, value: SubscriptionFormValues[K]) => void
|
||||
}
|
||||
|
||||
export function SubscriptionForm({ values, editing, onSubmit, onCancelEdit, onChange }: SubscriptionFormProps) {
|
||||
export function SubscriptionForm({ values, editing, busy, onSubmit, onCancelEdit, onChange }: SubscriptionFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
@@ -150,14 +151,15 @@ export function SubscriptionForm({ values, editing, onSubmit, onCancelEdit, onCh
|
||||
/>
|
||||
只下载免费资源
|
||||
</label>
|
||||
<button type="submit" className="neon-button md:col-span-1">
|
||||
{editing ? <Save size={16} /> : <Plus size={16} />}
|
||||
{editing ? '保存' : '添加'}
|
||||
<button type="submit" className="neon-button md:col-span-1 disabled:cursor-not-allowed disabled:opacity-60" disabled={busy}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : editing ? <Save size={16} /> : <Plus size={16} />}
|
||||
{busy ? '提交中…' : editing ? '保存' : '添加'}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelEdit}
|
||||
disabled={busy}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消编辑
|
||||
|
||||
@@ -19,6 +19,7 @@ export function SubscriptionsPage() {
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const [listError, setListError] = useState('')
|
||||
const [historyError, setHistoryError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
@@ -58,6 +59,8 @@ export function SubscriptionsPage() {
|
||||
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: formValues.name,
|
||||
@@ -95,6 +98,8 @@ export function SubscriptionsPage() {
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '创建失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +186,7 @@ export function SubscriptionsPage() {
|
||||
<SubscriptionForm
|
||||
values={formValues}
|
||||
editing={Boolean(editingId)}
|
||||
busy={saving}
|
||||
onSubmit={onCreate}
|
||||
onCancelEdit={resetForm}
|
||||
onChange={updateFormValue}
|
||||
|
||||
Reference in New Issue
Block a user