diff --git a/backend/internal/httpapi/dns_account_mutations.go b/backend/internal/httpapi/dns_account_mutations.go index dd3b53d..70b1d56 100644 --- a/backend/internal/httpapi/dns_account_mutations.go +++ b/backend/internal/httpapi/dns_account_mutations.go @@ -33,6 +33,8 @@ func (s *Server) writeDNSAccountError(c *gin.Context, err error) { s.writeError(c, http.StatusForbidden, "无权操作此 DNS 账户", nil) case errors.Is(err, repository.ErrDNSAccountConflict): s.writeError(c, http.StatusConflict, "DNS 账户已变更,请刷新后重试", nil) + case errors.Is(err, repository.ErrDNSAccountBound): + s.writeError(c, http.StatusConflict, err.Error(), nil) case errors.Is(err, repository.ErrDNSAccountBusy): s.writeError(c, http.StatusConflict, "DNS 账户仍有执行租约,请等待任务及挑战清理完成", nil) default: @@ -123,6 +125,7 @@ func (s *Server) renameManagedDNSAccount(c *gin.Context) { } var request struct { Name string `json:"name"` + ProjectID string `json:"project_id"` Revision int64 `json:"revision"` Credentials map[string]string `json:"credentials"` } @@ -139,14 +142,23 @@ func (s *Server) renameManagedDNSAccount(c *gin.Context) { if !s.authorizeProjectPermissions(c, item.ProjectID, domain.PermissionDNSManage) { return } + targetProjectID := strings.TrimSpace(request.ProjectID) + if targetProjectID == "" { + targetProjectID = item.ProjectID + } else if targetProjectID != item.ProjectID { + if !s.authorizeProjectPermissions(c, targetProjectID, domain.PermissionDNSManage) { + return + } + } if item.Revision != request.Revision { s.writeDNSAccountError(c, repository.ErrDNSAccountConflict) return } item.Name = strings.TrimSpace(request.Name) + item.ProjectID = targetProjectID item.CredentialRef = "secret://dns/validation" if err := item.Validate(); err != nil { - s.writeError(c, http.StatusBadRequest, "DNS 账户名称无效", nil) + s.writeError(c, http.StatusBadRequest, "DNS 账户名称或项目无效", nil) return } reference := "" @@ -156,7 +168,7 @@ func (s *Server) renameManagedDNSAccount(c *gin.Context) { return } } - updated, err := repo.UpdateDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, item.Name, reference) + updated, err := repo.UpdateDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, item.Name, reference, targetProjectID) if err != nil { s.writeDNSAccountError(c, err) return diff --git a/backend/internal/repository/dns_account_repository.go b/backend/internal/repository/dns_account_repository.go index 97c56c9..2ea248d 100644 --- a/backend/internal/repository/dns_account_repository.go +++ b/backend/internal/repository/dns_account_repository.go @@ -13,6 +13,8 @@ import ( var ErrDNSAccountConflict = errors.New("DNS 账户已变更,请刷新后重试") +var ErrDNSAccountBound = errors.New("该 DNS 账户已被当前项目下的域名绑定,请先解除绑定") + var ErrLegacyDNSAccountConflict = errors.New("旧系统 DNS 账户标识已被其他账户占用") // DNSAccountRepository 的公开读取不返回凭据引用;秘密读取只能使用独立的使用权限入口。 @@ -23,7 +25,7 @@ type DNSAccountRepository interface { GetVisibleDNSAccount(context.Context, string, string) (domain.DNSAccount, error) GetDNSAccountForUse(context.Context, string, string, string) (domain.DNSAccount, error) GetDNSAccountForVerification(context.Context, string, string) (domain.DNSAccount, error) - UpdateDNSAccount(context.Context, string, string, int64, string, string) (domain.DNSAccount, error) + UpdateDNSAccount(context.Context, string, string, int64, string, string, string) (domain.DNSAccount, error) RenameDNSAccount(context.Context, string, string, int64, string) (domain.DNSAccount, error) SetDNSAccountEnabled(context.Context, string, string, int64, bool) (domain.DNSAccount, error) RotateDNSAccountCredential(context.Context, string, string, int64, string) (domain.DNSAccount, error) @@ -205,15 +207,74 @@ func (r *SQLProjectMemberRepository) RenameDNSAccount(ctx context.Context, actor }) } -func (r *SQLProjectMemberRepository) UpdateDNSAccount(ctx context.Context, actorID, id string, revision int64, name, reference string) (domain.DNSAccount, error) { - return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.update", func(item *domain.DNSAccount) error { - item.Name = name - if reference != "" { - item.CredentialRef = reference - item.VerificationState = domain.DNSAccountUnverified +func (r *SQLProjectMemberRepository) UpdateDNSAccount(ctx context.Context, actorID, id string, revision int64, name, reference, projectID string) (domain.DNSAccount, error) { + if err := r.ensureDB(); err != nil { + return domain.DNSAccount{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.DNSAccount{}, err + } + defer tx.Rollback() + item, err := scanDNSAccount(tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE id = ?`+r.resourceWriteLock()), id)) + if errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccount{}, ErrProjectAccessDenied + } + if err != nil { + return domain.DNSAccount{}, err + } + if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionDNSManage); err != nil { + return domain.DNSAccount{}, err + } + targetProjectID := strings.TrimSpace(projectID) + if targetProjectID != "" && targetProjectID != item.ProjectID { + if err := r.authorizeProjectTransaction(ctx, tx, actorID, targetProjectID, domain.PermissionDNSManage); err != nil { + return domain.DNSAccount{}, err } - return nil - }) + var boundCount int + err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT COUNT(*) FROM domain_dns_accounts WHERE project_id = ? AND dns_account_id = ?`), item.ProjectID, id).Scan(&boundCount) + if err != nil { + return domain.DNSAccount{}, err + } + if boundCount > 0 { + return domain.DNSAccount{}, ErrDNSAccountBound + } + item.ProjectID = targetProjectID + } + if item.Revision != revision { + return domain.DNSAccount{}, ErrDNSAccountConflict + } + if err := r.rejectActiveDNSLease(ctx, tx, "account_id", id); err != nil { + return domain.DNSAccount{}, err + } + item.Name = name + if reference != "" { + item.CredentialRef = reference + item.VerificationState = domain.DNSAccountUnverified + } + item.Revision++ + item.UpdatedAt = time.Now().UTC() + if err := item.Validate(); err != nil { + return domain.DNSAccount{}, err + } + result, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE dns_accounts SET name = ?, project_id = ?, credential_ref = ?, status = ?, verification_state = ?, revision = ?, updated_at = ? WHERE id = ? AND revision = ?`), + item.Name, item.ProjectID, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.UpdatedAt, item.ID, revision) + if err != nil { + if r.driver == "sqlite" && isSQLiteWriteConflict(err) { + return domain.DNSAccount{}, ErrDNSAccountConflict + } + return domain.DNSAccount{}, errors.New("更新 DNS 账户失败,请检查凭据引用是否重复") + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + return domain.DNSAccount{}, ErrDNSAccountConflict + } + if err := writeDNSAccountAudit(ctx, tx, r.driver, actorID, item, "dns_account.update"); err != nil { + return domain.DNSAccount{}, err + } + if err := tx.Commit(); err != nil { + return domain.DNSAccount{}, err + } + return publicDNSAccount(item), nil } // 首次验证允许读取尚未验证的账户,但必须实时拥有管理权限,且账户未被禁用。 diff --git a/frontend/src/views/DNSAccounts/DNSAccountDialog.vue b/frontend/src/views/DNSAccounts/DNSAccountDialog.vue index 1479b56..de4443d 100644 --- a/frontend/src/views/DNSAccounts/DNSAccountDialog.vue +++ b/frontend/src/views/DNSAccounts/DNSAccountDialog.vue @@ -32,13 +32,13 @@ onBeforeUnmount(() => previousFocus?.focus?.()); 账户名称 +