package repository import ( "context" "database/sql" "errors" "fmt" "time" "git.pcux.cn/xuans/Scert/backend/internal/domain" ) var ErrDNSAccountConflict = errors.New("DNS 账户已变更,请刷新后重试") // DNSAccountRepository 的公开读取不返回凭据引用;秘密读取只能使用独立的使用权限入口。 // 删除及运行租约将在域名绑定接入后提供,当前不提供无引用保护的删除方法。 type DNSAccountRepository interface { CreateDNSAccount(context.Context, string, domain.DNSAccount) (domain.DNSAccount, error) ListVisibleDNSAccounts(context.Context, string) ([]domain.DNSAccount, error) GetVisibleDNSAccount(context.Context, string, string) (domain.DNSAccount, error) GetDNSAccountForUse(context.Context, 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) RecordDNSAccountVerification(context.Context, string, string, int64, bool) (domain.DNSAccount, error) } var _ DNSAccountRepository = (*SQLProjectMemberRepository)(nil) const dnsAccountColumns = `dns_accounts.id, dns_accounts.name, dns_accounts.project_id, dns_accounts.owner_user_id, dns_accounts.provider, dns_accounts.credential_ref, dns_accounts.status, dns_accounts.verification_state, dns_accounts.revision, dns_accounts.created_at, dns_accounts.updated_at` func scanDNSAccount(row scanner) (domain.DNSAccount, error) { var item domain.DNSAccount err := row.Scan(&item.ID, &item.Name, &item.ProjectID, &item.OwnerUserID, &item.Provider, &item.CredentialRef, &item.Status, &item.VerificationState, &item.Revision, &item.CreatedAt, &item.UpdatedAt) return item, err } func publicDNSAccount(item domain.DNSAccount) domain.DNSAccount { item.CredentialRef = "" return item } func (r *SQLProjectMemberRepository) CreateDNSAccount(ctx context.Context, actorID string, item domain.DNSAccount) (domain.DNSAccount, error) { if err := r.ensureDB(); err != nil { return domain.DNSAccount{}, err } // 用户归属、初始验证状态及版本只由服务端生成,不能由请求冒领或伪造验证成功。 item.OwnerUserID, item.Revision = actorID, 1 item.Status, item.VerificationState = domain.DNSAccountActive, domain.DNSAccountUnverified item.CreatedAt = time.Now().UTC() item.UpdatedAt = item.CreatedAt if err := item.Validate(); err != nil { return domain.DNSAccount{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.DNSAccount{}, err } defer tx.Rollback() if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionDNSManage); err != nil { return domain.DNSAccount{}, err } _, err = tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO dns_accounts (id, name, project_id, owner_user_id, provider, credential_ref, status, verification_state, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.ProjectID, item.OwnerUserID, item.Provider, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.CreatedAt, item.UpdatedAt) if err != nil { // 驱动错误可能回显唯一键中的凭据引用,不把原始错误传向外部接口。 return domain.DNSAccount{}, errors.New("保存 DNS 账户失败,请检查账户或凭据引用是否重复") } if err := writeDNSAccountAudit(ctx, tx, r.driver, actorID, item, "dns_account.create"); err != nil { return domain.DNSAccount{}, err } if err := tx.Commit(); err != nil { return domain.DNSAccount{}, err } return publicDNSAccount(item), nil } func (r *SQLProjectMemberRepository) ListVisibleDNSAccounts(ctx context.Context, actorID string) ([]domain.DNSAccount, error) { if err := r.resourceReaderReady(actorID); err != nil { return nil, err } predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSRead) rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+` ORDER BY dns_accounts.name, dns_accounts.id`), args...) if err != nil { return nil, fmt.Errorf("读取 DNS 账户列表失败:%w", err) } defer rows.Close() items := make([]domain.DNSAccount, 0) for rows.Next() { item, err := scanDNSAccount(rows) if err != nil { return nil, err } items = append(items, publicDNSAccount(item)) } return items, rows.Err() } func (r *SQLProjectMemberRepository) GetVisibleDNSAccount(ctx context.Context, actorID, id string) (domain.DNSAccount, error) { if err := r.resourceReaderReady(actorID); err != nil { return domain.DNSAccount{}, err } predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSRead) args = append(args, id) item, err := scanDNSAccount(r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+` AND dns_accounts.id = ?`), args...)) if errors.Is(err, sql.ErrNoRows) { return domain.DNSAccount{}, ErrProjectAccessDenied } return publicDNSAccount(item), err } // GetDNSAccountForUse 要求调用方传真实域名项目;禁用、未验证或跨项目账户不返回秘密引用。 // 返回的是检查时快照,签发执行仍须通过后续租约机制持有版本,不能把此读取当作执行锁。 func (r *SQLProjectMemberRepository) GetDNSAccountForUse(ctx context.Context, actorID, id, projectID string) (domain.DNSAccount, error) { if err := r.resourceReaderReady(actorID); err != nil { return domain.DNSAccount{}, err } predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSCredentialUse) args = append(args, id, projectID, domain.DNSAccountActive, domain.DNSAccountVerified) item, err := scanDNSAccount(r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+` AND dns_accounts.id = ? AND dns_accounts.project_id = ? AND dns_accounts.status = ? AND dns_accounts.verification_state = ?`), args...)) if errors.Is(err, sql.ErrNoRows) { return domain.DNSAccount{}, ErrProjectAccessDenied } return item, err } func (r *SQLProjectMemberRepository) RenameDNSAccount(ctx context.Context, actorID, id string, revision int64, name string) (domain.DNSAccount, error) { return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.rename", func(item *domain.DNSAccount) error { item.Name = name return nil }) } func (r *SQLProjectMemberRepository) SetDNSAccountEnabled(ctx context.Context, actorID, id string, revision int64, enabled bool) (domain.DNSAccount, error) { return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.status", func(item *domain.DNSAccount) error { item.Status = domain.DNSAccountDisabled if enabled { item.Status = domain.DNSAccountActive } return nil }) } func (r *SQLProjectMemberRepository) RotateDNSAccountCredential(ctx context.Context, actorID, id string, revision int64, reference string) (domain.DNSAccount, error) { return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.rotate", func(item *domain.DNSAccount) error { if reference == item.CredentialRef { return errors.New("DNS 凭据轮换必须使用新的秘密引用") } item.CredentialRef, item.VerificationState = reference, domain.DNSAccountUnverified return nil }) } // 验证调用者必须先完成真实 DNS 验证;此入口只接受开始验证时的版本,不接受客户端自报结果。 func (r *SQLProjectMemberRepository) RecordDNSAccountVerification(ctx context.Context, actorID, id string, revision int64, success bool) (domain.DNSAccount, error) { return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.verify", func(item *domain.DNSAccount) error { if item.Status != domain.DNSAccountActive { return errors.New("已禁用的 DNS 账户不能完成验证") } item.VerificationState = domain.DNSAccountVerificationFailed if success { item.VerificationState = domain.DNSAccountVerified } return nil }) } func (r *SQLProjectMemberRepository) changeDNSAccount(ctx context.Context, actorID, id string, revision int64, action string, change func(*domain.DNSAccount) error) (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 } if item.Revision != revision { return domain.DNSAccount{}, ErrDNSAccountConflict } if err := change(&item); err != nil { return domain.DNSAccount{}, err } 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 = ?, credential_ref = ?, status = ?, verification_state = ?, revision = ?, updated_at = ? WHERE id = ? AND revision = ?`), item.Name, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.UpdatedAt, item.ID, revision) if err != nil { 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, action); err != nil { return domain.DNSAccount{}, err } if err := tx.Commit(); err != nil { return domain.DNSAccount{}, err } return publicDNSAccount(item), nil } func writeDNSAccountAudit(ctx context.Context, tx *sql.Tx, driver, actorID string, item domain.DNSAccount, action string) error { result, err := tx.ExecContext(ctx, Rebind(driver, `INSERT INTO audit_logs (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) VALUES (?, 'dns_account', ?, 'success', 'DNS 账户资料已更新', ?, 'project', ?, '', ?)`), action, item.ID, time.Now().UTC(), item.ProjectID, actorID) if err != nil { return errors.New("写入 DNS 账户审计失败,变更已回滚") } if count, err := result.RowsAffected(); err != nil || count != 1 { return errors.New("DNS 账户审计未保存,变更已回滚") } return nil }