diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 2a6d4af6..ff291961 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -14,6 +14,7 @@ sidebar: false - 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。 ### 🛠 修复 +- 修复在网站列表中删除已加入 Cloudflare 指向分组的域名后,访问 Cloudflare 指向分组详情报错「Cloudflare 资源不存在」的问题。 - 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。 - 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。 - 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。 diff --git a/internal/apps/openflare/cloudflare/logics.go b/internal/apps/openflare/cloudflare/logics.go index 2078f4ac..8d14935a 100644 --- a/internal/apps/openflare/cloudflare/logics.go +++ b/internal/apps/openflare/cloudflare/logics.go @@ -423,6 +423,13 @@ func listMemberItems(ctx context.Context, groupID uint) ([]MemberItem, error) { for i := range members { domain, domainErr := repository.GetZoneDomainByID(ctx, members[i].ZoneDomainID) if domainErr != nil { + if errors.Is(domainErr, gorm.ErrRecordNotFound) { + logger.WarnF(ctx, "[Cloudflare] cleaning up orphaned pointing member: member_id=%d zone_domain_id=%d", members[i].ID, members[i].ZoneDomainID) + if delErr := repository.DeleteCFPointingMember(ctx, &members[i]); delErr != nil { + logger.ErrorF(ctx, "[Cloudflare] delete orphaned member failed: member_id=%d error=%v", members[i].ID, delErr) + } + continue + } return nil, domainErr } items = append(items, *memberItem(&members[i], domain)) diff --git a/internal/apps/openflare/cloudflare/reconcile.go b/internal/apps/openflare/cloudflare/reconcile.go index 096f9b6f..5f0a331e 100644 --- a/internal/apps/openflare/cloudflare/reconcile.go +++ b/internal/apps/openflare/cloudflare/reconcile.go @@ -14,6 +14,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/pkg/logger" + "gorm.io/gorm" ) const ( @@ -116,6 +117,9 @@ func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip s func DeleteManagedRecord(ctx context.Context, memberID uint) error { state, err := repository.GetCFPointingMemberContext(ctx, memberID) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } return err } connection, err := repository.GetCFConnection(ctx) diff --git a/internal/apps/openflare/cloudflare/routers_test.go b/internal/apps/openflare/cloudflare/routers_test.go index b750e8e3..d945de53 100644 --- a/internal/apps/openflare/cloudflare/routers_test.go +++ b/internal/apps/openflare/cloudflare/routers_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" ) @@ -41,3 +43,35 @@ func TestConnectionHandlersNeverReturnAPIToken(t *testing.T) { t.Fatalf("GET /connection leaked token: %s", get.Body.String()) } } + +func TestGetGroupWithOrphanedMemberHealsAndSucceeds(t *testing.T) { + ctx, memberID := setupCloudflareLogicDB(t) + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(response.ErrorHandlerMiddleware()) + router.GET("/groups/:id", GetGroupHandler) + + member, err := repository.GetCFPointingMemberByID(ctx, memberID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID() error = %v", err) + } + + // Simulate orphaned member by deleting the ZoneDomain directly + if err := db.DB(ctx).Exec("DELETE FROM of_zone_domains WHERE id = ?", member.ZoneDomainID).Error; err != nil { + t.Fatalf("DELETE FROM of_zone_domains error = %v", err) + } + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/groups/1", nil) + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("GET /groups/1 status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + + // Verify the orphaned member has been removed + _, err = repository.GetCFPointingMemberByID(ctx, memberID) + if err == nil { + t.Errorf("GetCFPointingMemberByID() should return not found after healing") + } +} diff --git a/internal/apps/openflare/cloudflare/tasks.go b/internal/apps/openflare/cloudflare/tasks.go index 3e9d79fd..828b79d4 100644 --- a/internal/apps/openflare/cloudflare/tasks.go +++ b/internal/apps/openflare/cloudflare/tasks.go @@ -15,6 +15,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/infra/task" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" + "gorm.io/gorm" ) const ( @@ -317,10 +318,19 @@ func executeBatchSync( return &task.TaskResult{Message: message}, nil } + syncedCount := 0 for index, member := range members { domainName := fmt.Sprintf("zone_domain_id=%d", member.ZoneDomainID) if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil { domainName = domain.Domain + } else if errors.Is(domainErr, gorm.ErrRecordNotFound) { + task.AppendLog(ctx, "[%d/%d] 域名记录已不存在,清理孤立成员: member_id=%d zone_domain_id=%d", + index+1, len(members), member.ID, member.ZoneDomainID) + if delErr := repository.DeleteCFPointingMember(ctx, &member); delErr != nil { + task.AppendLog(ctx, "[%d/%d] 清理孤立成员失败: member_id=%d error=%v", + index+1, len(members), member.ID, delErr) + } + continue } task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)", index+1, len(members), domainName, member.ID, member.Proxied) @@ -329,13 +339,14 @@ func executeBatchSync( index+1, len(members), domainName, member.ID, err) return nil, err } + syncedCount++ task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName) } - message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, len(members)) + message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, syncedCount) if activeNode != "" { message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名", - scope, scopeName, activeNode, len(members)) + scope, scopeName, activeNode, syncedCount) } task.AppendLog(ctx, "%s", message) return &task.TaskResult{Message: message}, nil diff --git a/internal/apps/openflare/zone/logics.go b/internal/apps/openflare/zone/logics.go index df518b67..34b27517 100644 --- a/internal/apps/openflare/zone/logics.go +++ b/internal/apps/openflare/zone/logics.go @@ -10,8 +10,10 @@ import ( "strings" "time" + cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/logger" "golang.org/x/net/publicsuffix" "gorm.io/gorm" ) @@ -220,6 +222,19 @@ func DeleteDomain(ctx context.Context, zoneID, id uint) error { if item.ProxyRouteID != nil { return errors.New(errDomainBoundToRoute) } + member, cfErr := repository.GetCFPointingMemberByZoneDomainID(ctx, item.ID) + if cfErr != nil && !errors.Is(cfErr, gorm.ErrRecordNotFound) { + return cfErr + } + if member != nil { + if delErr := cf.DeleteManagedRecord(ctx, member.ID); delErr != nil { + logger.WarnF(ctx, "[Zone] delete managed Cloudflare record failed for domain %s (member_id=%d): %v", item.Domain, member.ID, delErr) + } + if delMemberErr := repository.DeleteCFPointingMember(ctx, member); delMemberErr != nil { + logger.ErrorF(ctx, "[Zone] delete Cloudflare pointing member failed: member_id=%d error=%v", member.ID, delMemberErr) + return delMemberErr + } + } return repository.DeleteZoneDomain(ctx, item) } diff --git a/internal/apps/openflare/zone/logics_test.go b/internal/apps/openflare/zone/logics_test.go index b9585667..57b9e465 100644 --- a/internal/apps/openflare/zone/logics_test.go +++ b/internal/apps/openflare/zone/logics_test.go @@ -22,7 +22,7 @@ func setupZoneDB(t *testing.T) context.Context { t.Helper() conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true}) require.NoError(t, err) - require.NoError(t, conn.AutoMigrate(&model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{})) + require.NoError(t, conn.AutoMigrate(&model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{}, &model.CFPointingGroup{}, &model.CFPointingMember{})) db.SetDB(conn) t.Cleanup(func() { db.SetDB(nil) }) return context.Background() @@ -54,6 +54,22 @@ func TestDeleteDomainRejectsBoundRoute(t *testing.T) { require.NoError(t, DeleteDomain(ctx, zone.ID, item.ID)) } +func TestDeleteDomainCleansUpCloudflareMember(t *testing.T) { + ctx := setupZoneDB(t) + zone, err := Create(ctx, Input{Domain: "example.com"}) + require.NoError(t, err) + domain, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"}) + require.NoError(t, err) + + member := model.CFPointingMember{GroupID: 1, ZoneDomainID: domain.ID} + require.NoError(t, repository.CreateCFPointingMember(ctx, &member)) + + require.NoError(t, DeleteDomain(ctx, zone.ID, domain.ID)) + + _, err = repository.GetCFPointingMemberByZoneDomainID(ctx, domain.ID) + require.Error(t, err) +} + func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) { root, err := zoneRoot("api.example.co.uk") require.NoError(t, err) diff --git a/internal/repository/openflare_cloudflare.go b/internal/repository/openflare_cloudflare.go index 7158b7d6..0f28b117 100644 --- a/internal/repository/openflare_cloudflare.go +++ b/internal/repository/openflare_cloudflare.go @@ -91,7 +91,9 @@ func DeleteCFPointingGroup(ctx context.Context, id uint) error { // CountCFPointingMembersByGroupID counts members in a group. func CountCFPointingMembersByGroupID(ctx context.Context, groupID uint) (int64, error) { var count int64 - err := db.DB(ctx).Model(&model.CFPointingMember{}).Where("group_id = ?", groupID).Count(&count).Error + err := db.DB(ctx).Table("of_cf_pointing_members AS members"). + Joins("JOIN of_zone_domains AS domains ON domains.id = members.zone_domain_id"). + Where("members.group_id = ?", groupID).Count(&count).Error return count, err } diff --git a/internal/repository/openflare_zone.go b/internal/repository/openflare_zone.go index e73aa42c..b2d22f03 100644 --- a/internal/repository/openflare_zone.go +++ b/internal/repository/openflare_zone.go @@ -97,7 +97,16 @@ func SaveZoneDomain(ctx context.Context, domain *model.ZoneDomain) error { // DeleteZoneDomain deletes a zone domain record. func DeleteZoneDomain(ctx context.Context, domain *model.ZoneDomain) error { - return db.DB(ctx).Delete(domain).Error + conn := db.DB(ctx) + if conn == nil { + return errors.New(errDatabaseNotInitialized) + } + return conn.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("zone_domain_id = ?", domain.ID).Delete(&model.CFPointingMember{}).Error; err != nil { + return err + } + return tx.Delete(domain).Error + }) } // ListZoneDomainsByRouteID returns the domains bound to a proxy route.