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?.());
账户名称
+
-