From a6277573dda56a7e36a633b346e4d59554c415e2 Mon Sep 17 00:00:00 2001 From: King Date: Tue, 28 Jul 2026 16:12:16 +0800 Subject: [PATCH] Fix STRM branch retention --- internal/api/strm_admin.go | 22 +++++++-- internal/strm/branch_test.go | 94 ++++++++++++++++++++++++++++++++++++ internal/strm/service.go | 64 +++++++++++++++++++++++- 3 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 internal/strm/branch_test.go diff --git a/internal/api/strm_admin.go b/internal/api/strm_admin.go index 38fcbd9..a517247 100644 --- a/internal/api/strm_admin.go +++ b/internal/api/strm_admin.go @@ -67,6 +67,15 @@ type strmBranchDTO struct { Source string `json:"source,omitempty"` } +type strmBranchPatchDTO struct { + ParentID *string `json:"parent_id"` + Path *string `json:"path"` + Recursive *bool `json:"recursive"` + RetentionDays *int `json:"retention_days"` + BranchType *string `json:"branch_type"` + Status *string `json:"status"` +} + func (h *Handler) listStrmTasks(w http.ResponseWriter, r *http.Request) { if !ensureServiceReady(w, h.strm != nil) { return @@ -292,14 +301,19 @@ func (h *Handler) updateStrmBranch(w http.ResponseWriter, r *http.Request) { writeErr(w, err) return } - var in strmBranchDTO + var in strmBranchPatchDTO if err := decodeJSON(r, &in); err != nil { writeErr(w, err) return } - branch := fromStrmBranchDTO(taskID, in) - branch.ID = branchID - updated, err := h.strm.UpdateBranch(r.Context(), branch) + updated, err := h.strm.UpdateBranch(r.Context(), taskID, branchID, strm.BranchPatch{ + ParentID: in.ParentID, + Path: in.Path, + Recursive: in.Recursive, + RetentionDays: in.RetentionDays, + BranchType: in.BranchType, + Status: in.Status, + }) if err != nil { writeErr(w, err) return diff --git a/internal/strm/branch_test.go b/internal/strm/branch_test.go new file mode 100644 index 0000000..3a63629 --- /dev/null +++ b/internal/strm/branch_test.go @@ -0,0 +1,94 @@ +package strm + +import ( + "testing" + "time" + + "litepan/internal/domain" +) + +func TestUpdateBranchRetentionPreservesPathAndRefreshesExpiry(t *testing.T) { + svc, st := testService(t) + svc.branches = st.StrmBranches + ctx := t.Context() + + accountID, err := st.Accounts.Create(ctx, &domain.Account{ + Name: "测试账号", + DriverType: "localfs", + IsActive: true, + }) + if err != nil { + t.Fatal(err) + } + taskID, err := st.StrmTasks.Create(ctx, &domain.StrmTask{ + Name: "电视剧", + AccountID: accountID, + ParentID: "library", + Path: "/云影音/电视剧", + Status: domain.StrmStatusActive, + }) + if err != nil { + t.Fatal(err) + } + branch, err := svc.CreateBranch(ctx, &domain.StrmBranch{ + TaskID: taskID, + ParentID: "one-piece", + Path: "/云影音/电视剧/海贼王", + Recursive: true, + RetentionDays: 30, + BranchType: domain.StrmBranchTypeTemporary, + }) + if err != nil { + t.Fatal(err) + } + if branch.ExpiresAt.IsZero() { + t.Fatal("监控分支创建后应按保留天数设置过期时间") + } + + days := 90 + updated, err := svc.UpdateBranch(ctx, taskID, branch.ID, BranchPatch{RetentionDays: &days}) + if err != nil { + t.Fatal(err) + } + if updated.ParentID != branch.ParentID || updated.Path != branch.Path || updated.RelativePath != branch.RelativePath { + t.Fatalf("仅修改保留天数不应改变目录信息,更新前=%+v,更新后=%+v", branch, updated) + } + if !updated.Recursive || updated.BranchType != domain.StrmBranchTypeTemporary || updated.Source != "manual" { + t.Fatalf("仅修改保留天数不应改变其他分支属性: %+v", updated) + } + if updated.RetentionDays != days { + t.Fatalf("保留天数=%d,期望=%d", updated.RetentionDays, days) + } + expectedExpiry := branch.CreatedAt.Add(90 * 24 * time.Hour) + if !updated.ExpiresAt.Equal(expectedExpiry) { + t.Fatalf("过期时间=%v,期望按创建时间计算为 %v", updated.ExpiresAt, expectedExpiry) + } + + permanent := 0 + updated, err = svc.UpdateBranch(ctx, taskID, branch.ID, BranchPatch{RetentionDays: &permanent}) + if err != nil { + t.Fatal(err) + } + if !updated.ExpiresAt.IsZero() { + t.Fatalf("永久保留应清空过期时间,实际=%v", updated.ExpiresAt) + } + if updated.Path != branch.Path { + t.Fatalf("改为永久保留后路径被改变为 %q", updated.Path) + } +} + +func TestNormalizeTemporaryBranchExpiryUsesCreatedAt(t *testing.T) { + createdAt := time.Date(2026, 1, 1, 8, 0, 0, 0, time.Local) + branch := &domain.StrmBranch{ + RetentionDays: 90, + CreatedAt: createdAt, + } + + if err := normalizeTemporaryBranchExpiry(branch, true); err != nil { + t.Fatal(err) + } + expected := createdAt.Add(90 * 24 * time.Hour) + if !branch.ExpiresAt.Equal(expected) { + t.Fatalf("过期时间=%v,期望=%v", branch.ExpiresAt, expected) + } +} diff --git a/internal/strm/service.go b/internal/strm/service.go index 8d975f9..e866c45 100644 --- a/internal/strm/service.go +++ b/internal/strm/service.go @@ -608,6 +608,15 @@ func (s *Service) ListBranches(ctx context.Context, taskID int64) ([]*domain.Str return s.branches.ListByTask(ctx, taskID) } +type BranchPatch struct { + ParentID *string + Path *string + Recursive *bool + RetentionDays *int + BranchType *string + Status *string +} + func (s *Service) CreateBranch(ctx context.Context, branch *domain.StrmBranch) (*domain.StrmBranch, error) { if s.branches == nil { return nil, domain.Errf(domain.CodeNotImplement) @@ -624,6 +633,9 @@ func (s *Service) CreateBranch(ctx context.Context, branch *domain.StrmBranch) ( if branch.BranchType == domain.StrmBranchTypeBase { branch.Recursive = false branch.RetentionDays = 0 + branch.ExpiresAt = time.Time{} + } else if err := normalizeTemporaryBranchExpiry(branch, false); err != nil { + return nil, err } if branch.Status == "" { branch.Status = "running" @@ -638,17 +650,47 @@ func (s *Service) CreateBranch(ctx context.Context, branch *domain.StrmBranch) ( return s.branches.Get(ctx, id) } -func (s *Service) UpdateBranch(ctx context.Context, branch *domain.StrmBranch) (*domain.StrmBranch, error) { +func (s *Service) UpdateBranch(ctx context.Context, taskID, branchID int64, patch BranchPatch) (*domain.StrmBranch, error) { if s.branches == nil { return nil, domain.Errf(domain.CodeNotImplement) } - task, err := s.repo.Get(ctx, branch.TaskID) + branch, err := s.branches.Get(ctx, branchID) if err != nil { return nil, err } + if branch.TaskID != taskID { + return nil, domain.Errorf(domain.CodeNotFound, "STRM 分支不存在") + } + task, err := s.repo.Get(ctx, taskID) + if err != nil { + return nil, err + } + if patch.ParentID != nil { + branch.ParentID = *patch.ParentID + } + if patch.Path != nil { + branch.Path = *patch.Path + } + if patch.Recursive != nil { + branch.Recursive = *patch.Recursive + } + retentionChanged := patch.RetentionDays != nil + if retentionChanged { + branch.RetentionDays = *patch.RetentionDays + } + if patch.BranchType != nil { + branch.BranchType = *patch.BranchType + } + if patch.Status != nil { + branch.Status = *patch.Status + } branch.RelativePath = branchRelativePath(task.Path, branch.Path) if branch.BranchType == domain.StrmBranchTypeBase { branch.Recursive = false + branch.RetentionDays = 0 + branch.ExpiresAt = time.Time{} + } else if err := normalizeTemporaryBranchExpiry(branch, retentionChanged); err != nil { + return nil, err } if err := s.branches.Update(ctx, branch); err != nil { return nil, err @@ -656,6 +698,24 @@ func (s *Service) UpdateBranch(ctx context.Context, branch *domain.StrmBranch) ( return s.branches.Get(ctx, branch.ID) } +func normalizeTemporaryBranchExpiry(branch *domain.StrmBranch, reset bool) error { + if branch.RetentionDays < 0 || branch.RetentionDays > 3650 { + return domain.Errorf(domain.CodeValidation, "监控分支保留天数需为 0 到 3650") + } + if branch.RetentionDays == 0 { + branch.ExpiresAt = time.Time{} + return nil + } + if reset || branch.ExpiresAt.IsZero() { + baseTime := branch.CreatedAt + if baseTime.IsZero() { + baseTime = time.Now() + } + branch.ExpiresAt = baseTime.Add(time.Duration(branch.RetentionDays) * 24 * time.Hour) + } + return nil +} + func (s *Service) DeleteBranch(ctx context.Context, id int64) error { if s.branches == nil { return domain.Errf(domain.CodeNotImplement)