diff --git a/internal/apps/openflare/cloudflare/errs.go b/internal/apps/openflare/cloudflare/errs.go index 13db374b..0a3fb85e 100644 --- a/internal/apps/openflare/cloudflare/errs.go +++ b/internal/apps/openflare/cloudflare/errs.go @@ -19,4 +19,7 @@ const ( errSyncFailed = "Cloudflare DNS 同步失败" errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败" errTaskDispatchFailed = "无法投递 Cloudflare 同步任务" + errTargetGroupSame = "目标分组不能为当前分组" + errTargetGroupInvalid = "目标分组不存在" + errNoMembersSelected = "未选择任何成员" ) diff --git a/internal/apps/openflare/cloudflare/logics.go b/internal/apps/openflare/cloudflare/logics.go index d6b2dbf9..848d97bf 100644 --- a/internal/apps/openflare/cloudflare/logics.go +++ b/internal/apps/openflare/cloudflare/logics.go @@ -387,6 +387,96 @@ func RemoveMember(ctx context.Context, groupID, memberID uint) error { return repository.DeleteCFPointingMember(ctx, member) } +// MoveMember transfers a member from sourceGroupID to targetGroupID. +func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) { + if targetGroupID == 0 || targetGroupID == sourceGroupID { + return nil, errors.New(errTargetGroupSame) + } + targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New(errTargetGroupInvalid) + } + return nil, err + } + member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID) + if err != nil { + return nil, err + } + member.GroupID = targetGroupID + member.SyncStatus = model.CFMemberSyncPending + member.LastError = "" + if err = repository.SaveCFPointingMember(ctx, member); err != nil { + return nil, err + } + if targetGroup.Enabled { + if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil { + logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err) + } + } + domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID) + if err != nil { + return nil, err + } + return memberItem(member, domain), nil +} + +// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID. +func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error { + if len(input.MemberIDs) == 0 { + return errors.New(errNoMembersSelected) + } + if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID { + return errors.New(errTargetGroupSame) + } + targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(errTargetGroupInvalid) + } + return err + } + for _, memberID := range input.MemberIDs { + member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID) + if getErr != nil { + continue + } + member.GroupID = input.TargetGroupID + member.SyncStatus = model.CFMemberSyncPending + member.LastError = "" + if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil { + logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr) + continue + } + if targetGroup.Enabled { + if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil { + logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr) + } + } + } + return nil +} + +// BatchRemoveMembers deletes multiple members and their remote A records. +func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error { + if len(input.MemberIDs) == 0 { + return errors.New(errNoMembersSelected) + } + for _, memberID := range input.MemberIDs { + member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID) + if err != nil { + continue + } + if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil { + logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr) + } + if err = repository.DeleteCFPointingMember(ctx, member); err != nil { + logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err) + } + } + return nil +} + // DeleteGroup removes every managed remote A record and then local state. func DeleteGroup(ctx context.Context, groupID uint) error { if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil { diff --git a/internal/apps/openflare/cloudflare/logics_test.go b/internal/apps/openflare/cloudflare/logics_test.go new file mode 100644 index 00000000..93fbb1d5 --- /dev/null +++ b/internal/apps/openflare/cloudflare/logics_test.go @@ -0,0 +1,193 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cloudflare + +import ( + "context" + "testing" + + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/repository" +) + +func TestMoveMemberAndBatchOperations(t *testing.T) { + ctx, member1ID := setupCloudflareLogicDB(t) + + var dispatchedTasks []string + restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { + dispatchedTasks = append(dispatchedTasks, triggeredBy) + return "mock-task-id", nil + }) + t.Cleanup(restoreDispatch) + + fake := &fakeClient{} + restoreClient := SetClientFactoryForTest(func(string) Client { return fake }) + t.Cleanup(restoreClient) + + member1, err := repository.GetCFPointingMemberByID(ctx, member1ID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID() error = %v", err) + } + sourceGroupID := member1.GroupID + + // Create target group + targetGroup := model.CFPointingGroup{ + Name: "secondary", + PrimaryNodeID: 1, + ActiveNodeID: 1, + DefaultProxied: true, + Enabled: true, + } + if err := db.DB(ctx).Create(&targetGroup).Error; err != nil { + t.Fatalf("Create(targetGroup) error = %v", err) + } + + // 1. Target group equals source group -> errTargetGroupSame + t.Run("MoveMember target equals source", func(t *testing.T) { + _, err := MoveMember(ctx, sourceGroupID, member1.ID, sourceGroupID) + if err == nil || err.Error() != errTargetGroupSame { + t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupSame) + } + }) + + // 2. Target group does not exist -> errTargetGroupInvalid + t.Run("MoveMember target group invalid", func(t *testing.T) { + _, err := MoveMember(ctx, sourceGroupID, member1.ID, 99999) + if err == nil || err.Error() != errTargetGroupInvalid { + t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupInvalid) + } + }) + + // 3. Successfully move member to target group + t.Run("MoveMember success", func(t *testing.T) { + item, err := MoveMember(ctx, sourceGroupID, member1.ID, targetGroup.ID) + if err != nil { + t.Fatalf("MoveMember() error = %v", err) + } + if item == nil || item.GroupID != targetGroup.ID { + t.Fatalf("MoveMember() returned item group ID = %v, want %d", item, targetGroup.ID) + } + + updated, err := repository.GetCFPointingMemberByID(ctx, member1.ID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID() error = %v", err) + } + if updated.GroupID != targetGroup.ID { + t.Errorf("member GroupID = %d, want %d", updated.GroupID, targetGroup.ID) + } + if updated.SyncStatus != model.CFMemberSyncPending { + t.Errorf("member SyncStatus = %s, want %s", updated.SyncStatus, model.CFMemberSyncPending) + } + if updated.LastError != "" { + t.Errorf("member LastError = %q, want empty", updated.LastError) + } + }) + + // 4. Batch move multiple members + t.Run("BatchMoveMembers", func(t *testing.T) { + // Test empty members error + emptyErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: nil, TargetGroupID: targetGroup.ID}) + if emptyErr == nil || emptyErr.Error() != errNoMembersSelected { + t.Fatalf("BatchMoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected) + } + + // Test target equals source error + sameErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: sourceGroupID}) + if sameErr == nil || sameErr.Error() != errTargetGroupSame { + t.Fatalf("BatchMoveMembers() same group error = %v, want %s", sameErr, errTargetGroupSame) + } + + // Test invalid target group error + invalidErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: 99999}) + if invalidErr == nil || invalidErr.Error() != errTargetGroupInvalid { + t.Fatalf("BatchMoveMembers() invalid group error = %v, want %s", invalidErr, errTargetGroupInvalid) + } + + // Create 2 additional members in sourceGroup + domain2 := model.ZoneDomain{ZoneID: 1, Domain: "test2.example.com"} + domain3 := model.ZoneDomain{ZoneID: 1, Domain: "test3.example.com"} + if err := db.DB(ctx).Create(&domain2).Error; err != nil { + t.Fatalf("Create(domain2) error = %v", err) + } + if err := db.DB(ctx).Create(&domain3).Error; err != nil { + t.Fatalf("Create(domain3) error = %v", err) + } + member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK} + member3 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain3.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK} + if err := db.DB(ctx).Create(&member2).Error; err != nil { + t.Fatalf("Create(member2) error = %v", err) + } + if err := db.DB(ctx).Create(&member3).Error; err != nil { + t.Fatalf("Create(member3) error = %v", err) + } + + // Perform batch move + if err := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{ + MemberIDs: []uint{member2.ID, member3.ID}, + TargetGroupID: targetGroup.ID, + }); err != nil { + t.Fatalf("BatchMoveMembers() error = %v", err) + } + + // Verify updated in DB + for _, mid := range []uint{member2.ID, member3.ID} { + m, err := repository.GetCFPointingMemberByID(ctx, mid) + if err != nil { + t.Fatalf("GetCFPointingMemberByID(%d) error = %v", mid, err) + } + if m.GroupID != targetGroup.ID { + t.Errorf("member %d GroupID = %d, want %d", mid, m.GroupID, targetGroup.ID) + } + if m.SyncStatus != model.CFMemberSyncPending { + t.Errorf("member %d SyncStatus = %s, want %s", mid, m.SyncStatus, model.CFMemberSyncPending) + } + if m.LastError != "" { + t.Errorf("member %d LastError = %q, want empty", mid, m.LastError) + } + } + }) + + // 5. Batch remove members + t.Run("BatchRemoveMembers", func(t *testing.T) { + // Test empty members error + emptyErr := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{MemberIDs: nil}) + if emptyErr == nil || emptyErr.Error() != errNoMembersSelected { + t.Fatalf("BatchRemoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected) + } + + // Create members to remove + domain4 := model.ZoneDomain{ZoneID: 1, Domain: "test4.example.com"} + domain5 := model.ZoneDomain{ZoneID: 1, Domain: "test5.example.com"} + if err := db.DB(ctx).Create(&domain4).Error; err != nil { + t.Fatalf("Create(domain4) error = %v", err) + } + if err := db.DB(ctx).Create(&domain5).Error; err != nil { + t.Fatalf("Create(domain5) error = %v", err) + } + member4 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain4.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK} + member5 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain5.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK} + if err := db.DB(ctx).Create(&member4).Error; err != nil { + t.Fatalf("Create(member4) error = %v", err) + } + if err := db.DB(ctx).Create(&member5).Error; err != nil { + t.Fatalf("Create(member5) error = %v", err) + } + + // Perform batch remove + if err := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{ + MemberIDs: []uint{member4.ID, member5.ID}, + }); err != nil { + t.Fatalf("BatchRemoveMembers() error = %v", err) + } + + // Verify deleted from DB + for _, mid := range []uint{member4.ID, member5.ID} { + _, err := repository.GetCFPointingMemberByID(ctx, mid) + if err == nil { + t.Errorf("GetCFPointingMemberByID(%d) should be deleted, but found", mid) + } + } + }) +} diff --git a/internal/apps/openflare/cloudflare/types.go b/internal/apps/openflare/cloudflare/types.go index 2c184484..088b3836 100644 --- a/internal/apps/openflare/cloudflare/types.go +++ b/internal/apps/openflare/cloudflare/types.go @@ -63,6 +63,22 @@ type MemberUpdateInput struct { Proxied bool `json:"proxied"` } +// MemberMoveInput contains the target group ID for moving a member. +type MemberMoveInput struct { + TargetGroupID uint `json:"target_group_id"` +} + +// MemberBatchMoveInput contains the member IDs and target group ID for batch moving. +type MemberBatchMoveInput struct { + MemberIDs []uint `json:"member_ids"` + TargetGroupID uint `json:"target_group_id"` +} + +// MemberBatchRemoveInput contains the member IDs for batch deletion. +type MemberBatchRemoveInput struct { + MemberIDs []uint `json:"member_ids"` +} + // MemberItem is the admin-facing member state. type MemberItem struct { ID uint `json:"id"`