feat: 支持修改DNS账户归属项目及防冲突校验

This commit is contained in:
xuans 2026-10-16 18:29:15 +08:00
parent 673727962d
commit 3e260cf6d1
4 changed files with 103 additions and 20 deletions

View File

@ -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

View File

@ -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
}
// 首次验证允许读取尚未验证的账户,但必须实时拥有管理权限,且账户未被禁用。

View File

@ -32,13 +32,13 @@ onBeforeUnmount(() => previousFocus?.focus?.());
账户名称
<input v-model="editor.name" autofocus required maxlength="128" aria-label="账户名称" />
</label>
<label v-if="['create', 'edit'].includes(editor.action)">
归属项目
<select v-model="editor.project_id" required aria-label="归属项目">
<option v-for="project in projects" :key="project.id" :value="project.id">{{ project.name }}</option>
</select>
</label>
<template v-if="editor.action === 'create'">
<label>
归属项目
<select v-model="editor.project_id" required aria-label="归属项目">
<option v-for="project in projects" :key="project.id" :value="project.id">{{ project.name }}</option>
</select>
</label>
<label>
DNS 服务商
<select v-model="editor.provider" required aria-label="DNS 服务商" @change="emit('provider-change')">

View File

@ -55,9 +55,16 @@ export function useDNSAccountEditor(projects, providers, loading, refresh) {
const form = editor.value;
if (!mounted || !form || saving.value || loading.value) return;
mutationError.value = "";
if (form.action === "create" ? !managedProjects.value.some((item) => item.id === form.project_id) : !canManage(target)) {
mutationError.value = "当前项目没有 DNS 管理权限,请刷新列表";
return;
if (form.action === "create") {
if (!managedProjects.value.some((item) => item.id === form.project_id)) {
mutationError.value = "当前项目没有 DNS 管理权限,请刷新列表";
return;
}
} else {
if (!canManage(target) || (form.action === "edit" && !managedProjects.value.some((item) => item.id === form.project_id))) {
mutationError.value = "目标项目没有 DNS 管理权限,请刷新列表";
return;
}
}
const body = form.action === "create" ? {} : { revision: target.revision };
if (["create", "edit", "rename"].includes(form.action)) {
@ -67,6 +74,9 @@ export function useDNSAccountEditor(projects, providers, loading, refresh) {
return;
}
body.name = name;
if (form.action === "edit" && form.project_id && form.project_id !== target?.project_id) {
body.project_id = form.project_id;
}
}
if (["create", "edit", "rotate"].includes(form.action)) {
const provider = providers.value.find((item) => item.id === (target?.provider || form.provider));