diff --git a/backend/internal/httpapi/dns_accounts_api.go b/backend/internal/httpapi/dns_accounts_api.go index 629b39a..0c62b3e 100644 --- a/backend/internal/httpapi/dns_accounts_api.go +++ b/backend/internal/httpapi/dns_accounts_api.go @@ -36,6 +36,12 @@ func (s *Server) listDNSAccounts(c *gin.Context) { cfg := s.config s.configMu.RUnlock() items := make([]dnsAccountView, 0, 1) + hasLegacyAccount := false + var legacyAccount domain.DNSAccount + configuredLegacyProjectID := strings.TrimSpace(cfg.DNS.ProjectID) + if configuredLegacyProjectID == "" { + configuredLegacyProjectID = domain.DefaultProjectID + } if repo, ok := s.projectMembers.(repository.DNSAccountRepository); ok { accounts, err := repo.ListVisibleDNSAccounts(c.Request.Context(), c.GetString(authenticatedActorKey)) if err != nil { @@ -43,6 +49,14 @@ func (s *Server) listDNSAccounts(c *gin.Context) { return } for _, account := range accounts { + if account.ID == "legacy-system-dns" { + if account.ProjectID != configuredLegacyProjectID { + continue + } + hasLegacyAccount = true + legacyAccount = account + continue + } item, err := s.managedDNSAccountView(c, account) if err != nil { s.writeError(c, http.StatusServiceUnavailable, "读取 DNS 账户归属失败", nil) @@ -78,6 +92,54 @@ func (s *Server) listDNSAccounts(c *gin.Context) { s.writeError(c, http.StatusServiceUnavailable, "读取项目 DNS 权限失败,请稍后重试", nil) return } + if hasLegacyAccount { + item, viewErr := s.configuredLegacyDNSAccountView(c, legacyAccount, cfg.DNS.Provider, cfg.DNS.CredentialRef, cfg.DNS.OwnerUserID, cfg.Admin.Username) + if viewErr != nil { + s.writeError(c, http.StatusServiceUnavailable, "读取 DNS 账户归属失败", nil) + return + } + items = append(items, item) + c.JSON(http.StatusOK, gin.H{"items": items}) + return + } + if importer, ok := s.projectMembers.(repository.DNSLegacyAccountImporter); ok { + if managedRepo, repoOK := s.projectMembers.(repository.DNSAccountRepository); repoOK && strings.TrimSpace(cfg.DNS.Provider) != "" { + const legacyID = "legacy-system-dns" + legacyAccount, err := managedRepo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), legacyID) + if errors.Is(err, repository.ErrProjectAccessDenied) { + legacyProjectID := strings.TrimSpace(cfg.DNS.ProjectID) + if legacyProjectID == "" { + legacyProjectID = domain.DefaultProjectID + } + legacyOwnerID := strings.TrimSpace(cfg.DNS.OwnerUserID) + if legacyOwnerID == "" { + legacyOwnerID = "primary-super-admin" + } + if err := importer.ImportLegacyDNSAccount(c.Request.Context(), domain.DNSAccount{ + ID: legacyID, Name: "旧系统 DNS 账户", ProjectID: legacyProjectID, + OwnerUserID: legacyOwnerID, Provider: cfg.DNS.Provider, CredentialRef: cfg.DNS.CredentialRef, + }); err != nil { + s.writeError(c, http.StatusServiceUnavailable, "登记已配置的 DNS 账户失败,请稍后重试", nil) + return + } + legacyAccount, err = managedRepo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), legacyID) + } + if err == nil { + item, viewErr := s.configuredLegacyDNSAccountView(c, legacyAccount, cfg.DNS.Provider, cfg.DNS.CredentialRef, cfg.DNS.OwnerUserID, cfg.Admin.Username) + if viewErr != nil { + s.writeError(c, http.StatusServiceUnavailable, "读取 DNS 账户归属失败", nil) + return + } + items = append(items, item) + c.JSON(http.StatusOK, gin.H{"items": items}) + return + } + if !errors.Is(err, repository.ErrProjectAccessDenied) { + s.writeError(c, http.StatusServiceUnavailable, "读取已配置的 DNS 账户失败,请稍后重试", nil) + return + } + } + } item := dnsAccountView{ ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider, ProjectID: projectID, @@ -129,6 +191,53 @@ func (s *Server) listDNSAccounts(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items}) } +func (s *Server) configuredLegacyDNSAccountView(c *gin.Context, account domain.DNSAccount, provider, credentialRef, ownerUserID, adminUsername string) (dnsAccountView, error) { + ownerID := strings.TrimSpace(ownerUserID) + if ownerID == "" { + ownerID = account.OwnerUserID + } + if ownerID == "" { + ownerID = "primary-super-admin" + } + item := dnsAccountView{ + ID: account.ID, Name: account.Name, Provider: account.Provider, ProjectID: account.ProjectID, + OwnerUserID: ownerID, OwnerState: "unassigned", OwnerUsername: "未分配", + VerificationState: account.VerificationState, CredentialState: "available", + Status: account.Status, Revision: account.Revision, Managed: true, + } + if descriptor, err := dnsprovider.GetDescriptor(provider); err == nil { + item.ProviderName = descriptor.Name + } else { + item.ProviderName = "未知服务商" + } + if ownerID == "primary-super-admin" { + item.OwnerState, item.OwnerUsername = "assigned", adminUsername + } else { + if s.users == nil { + return dnsAccountView{}, errors.New("用户归属查询暂不可用") + } + owner, err := s.users.GetUserByID(c.Request.Context(), ownerID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return dnsAccountView{}, err + } + if errors.Is(err, sql.ErrNoRows) { + item.OwnerState, item.OwnerUsername = "missing", "原用户已不存在" + } else { + item.OwnerState, item.OwnerUsername = "assigned", owner.Username + } + } + credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef) + if err != nil { + item.CredentialState, item.AccountIdentifier = "unavailable", "暂不可读取" + } else { + item.AccountIdentifier = dnsAccountIdentifier(provider, credentials) + if _, err := dnsprovider.ValidateCredentials(provider, credentials); err != nil { + item.CredentialState = "invalid" + } + } + return item, nil +} + func (s *Server) managedDNSAccountView(c *gin.Context, account domain.DNSAccount) (dnsAccountView, error) { item := dnsAccountView{ ID: account.ID, Name: account.Name, Provider: account.Provider, ProviderName: "未知服务商", diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index 2db24fb..3abcc3e 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -103,6 +103,34 @@ func TestAdminDNSAccountsExposeProjectScope(t *testing.T) { } } +func TestDNSAccountsLazilyImportConfiguredAccountForEditing(t *testing.T) { + f := newProjectMemberAPIFixture(t) + f.server.config.DNS.ProjectID = "project-a" + f.server.config.DNS.Provider = "cloudflare" + f.server.config.DNS.CredentialRef = "secret://dns/cloudflare/project-a" + f.server.secrets = &fakeSecretReaderStore{data: map[string]map[string]string{ + f.server.config.DNS.CredentialRef: {"api_token": "test-token"}, + }} + + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/dns/accounts", "") + if response.Code != http.StatusOK { + t.Fatalf("读取已配置 DNS 账户失败:%d %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"id":"legacy-system-dns"`) || + !strings.Contains(response.Body.String(), `"managed":true`) { + t.Fatalf("已配置 DNS 账户未迁移为可编辑账户:%s", response.Body.String()) + } + if strings.Contains(response.Body.String(), `"id":"system-dns"`) { + t.Fatalf("已迁移账户仍重复显示旧系统只读视图:%s", response.Body.String()) + } + + updated := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, + "/api/v1/dns/accounts/legacy-system-dns", `{"revision":1,"name":"修改后的账户"}`) + if updated.Code != http.StatusOK { + t.Fatalf("已配置 DNS 账户不能修改:%d %s", updated.Code, updated.Body.String()) + } +} + func TestDNSCredentialUseRejectsUnauthorizedProject(t *testing.T) { server := New(Dependencies{ ProjectDomains: projectDomainRepositoryStub{authorizeErr: repository.ErrProjectAccessDenied}, diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index b5f4f59..85387b0 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -177,7 +177,9 @@ - [✓] 编辑表单不回显 AccessKey、Secret、Token 等旧敏感值;留空表示保留当前凭据。 - [✓] 填写任意新凭据时使用白名单字段替换秘密引用,并把账户状态置为待重新验证。 - [✓] 编辑请求在写入秘密前核对实时项目 DNS 管理权限和账户版本;越权或过期请求不会写入秘密。 -- [x] 后端生命周期、权限隔离和前端 26 项专项测试已通过,等待全量回归及发布复审。 +- [✓] 已配置账户首次读取时按当前项目权限幂等导入为可编辑账户,不再重复展示 `system-dns` 只读兼容视图。 +- [✓] 延迟导入不会绕过项目 DNS 权限;无权用户不会触发导入或读取秘密仓库。 +- [✓] 后端全量测试、前端 349 项测试、`go vet ./...` 和前端生产构建均已通过,等待发布复审。 - [ ] 真实 DNS 服务商在线验证、三数据库实机回归和第一台候选包上线仍未完成。 ### 验收边界