新增DNS账户管理及归属显示

增加 DNS 账户列表接口、归属用户记录、脱敏账号标识和管理页面导航,补充前端回归覆盖。
This commit is contained in:
xuans 2026-09-26 12:29:03 +08:00
parent 443df8165e
commit e6c40d85a4
12 changed files with 407 additions and 4 deletions

View File

@ -90,6 +90,7 @@ type ACMEConfig struct {
}
type DNSConfig struct {
OwnerUserID string `json:"owner_user_id,omitempty"`
Provider string `json:"provider"`
CredentialRef string `json:"credential_ref"`
VerificationState string `json:"verification_state"`

View File

@ -0,0 +1,107 @@
package httpapi
import (
"database/sql"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
type dnsAccountView struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
AccountIdentifier string `json:"account_identifier"`
OwnerUserID string `json:"owner_user_id,omitempty"`
OwnerUsername string `json:"owner_username"`
OwnerState string `json:"owner_state"`
VerificationState string `json:"verification_state"`
CredentialState string `json:"credential_state"`
}
// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。
func (s *Server) listDNSAccounts(c *gin.Context) {
c.Header("Cache-Control", "no-store")
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
items := make([]dnsAccountView, 0, 1)
if strings.TrimSpace(cfg.DNS.CredentialRef) == "" {
c.JSON(http.StatusOK, gin.H{"items": items})
return
}
item := dnsAccountView{
ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider,
ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识",
OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配",
VerificationState: cfg.DNS.VerificationState, CredentialState: "available",
}
var owner repository.User
if cfg.DNS.OwnerUserID == "primary-super-admin" {
owner = repository.User{ID: cfg.DNS.OwnerUserID, Username: cfg.Admin.Username, Role: repository.UserRoleSuperAdmin}
} else if cfg.DNS.OwnerUserID != "" {
if s.users == nil {
s.writeError(c, http.StatusServiceUnavailable, "用户归属查询暂不可用", nil)
return
}
var err error
owner, err = s.users.GetUserByID(c.Request.Context(), cfg.DNS.OwnerUserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
s.writeError(c, http.StatusServiceUnavailable, "用户归属查询暂不可用", nil)
return
}
if errors.Is(err, sql.ErrNoRows) {
item.OwnerState, item.OwnerUsername = "missing", "原用户已不存在"
}
}
if owner.ID != "" {
item.OwnerState, item.OwnerUsername = "assigned", owner.Username
}
if descriptor, err := dnsprovider.GetDescriptor(cfg.DNS.Provider); err == nil {
item.ProviderName = descriptor.Name
}
credentials, err := s.readDNSCredentials(c.Request.Context(), cfg.DNS.CredentialRef)
if err != nil {
item.CredentialState, item.AccountIdentifier = "unavailable", "暂不可读取"
} else {
item.AccountIdentifier = dnsAccountIdentifier(cfg.DNS.Provider, credentials)
if _, err := dnsprovider.ValidateCredentials(cfg.DNS.Provider, credentials); err != nil {
item.CredentialState = "invalid"
}
}
items = append(items, item)
c.JSON(http.StatusOK, gin.H{"items": items})
}
// dnsAccountIdentifier 只允许输出公开标识的脱敏结果,绝不输出令牌或私钥片段。
func dnsAccountIdentifier(provider string, credentials map[string]string) string {
var value string
switch provider {
case dnsprovider.Aliyun, dnsprovider.HuaweiCloud, dnsprovider.Route53:
value = credentials["access_key_id"]
case dnsprovider.DNSPod, dnsprovider.TencentCloud:
value = credentials["secret_id"]
case dnsprovider.Cloudflare:
return "令牌账号(已隐藏)"
case dnsprovider.GoogleCloud:
return "服务账号(已隐藏)"
case dnsprovider.CNAME:
return "委派账号(已隐藏)"
default:
return "账号标识已隐藏"
}
identifier := []rune(strings.TrimSpace(value))
if len(identifier) == 0 {
return "未提供账号标识"
}
if len(identifier) <= 8 {
return "********"
}
return string(identifier[:4]) + "****" + string(identifier[len(identifier)-4:])
}

View File

@ -196,6 +196,7 @@ func (s *Server) registerRoutes() {
adminAPI.GET("/settings", s.getSettings)
adminAPI.PUT("/settings", s.updateSettings)
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
adminAPI.GET("/dns/accounts", s.listDNSAccounts)
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)

View File

@ -170,6 +170,11 @@ func (s *Server) getSettings(c *gin.Context) {
}
func (s *Server) updateSettings(c *gin.Context) {
session, authenticated := s.currentAuthSession(c)
if !authenticated {
s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil)
return
}
if strings.TrimSpace(s.configPath) == "" {
s.writeError(c, http.StatusInternalServerError, "配置文件路径未配置", errors.New("配置文件路径为空"))
return
@ -210,6 +215,12 @@ func (s *Server) updateSettings(c *gin.Context) {
if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef {
createdCredentialRefs = append(createdCredentialRefs, dnsCredentialRef)
}
// 只有实际更换凭据才记录新归属,修改其他设置不能认领历史账号。
if updated.DNS.CredentialRef == "" {
updated.DNS.OwnerUserID = ""
} else if updated.DNS.CredentialRef != previous.DNS.CredentialRef {
updated.DNS.OwnerUserID = session.UserID
}
if err := updated.Validate(); err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
s.configMu.Unlock()

View File

@ -130,6 +130,7 @@ func Apply(cfg *config.Config, input Input) error {
cfg.Paths.Certificate = strings.TrimSpace(input.CertificateRoot)
cfg.DNS.Provider = strings.TrimSpace(input.DNSProvider)
cfg.DNS.CredentialRef = strings.TrimSpace(input.DNSCredentialRef)
cfg.DNS.OwnerUserID = "primary-super-admin"
cfg.DNS.VerificationState = "verified"
cfg.Notification.HTTPEnabled = input.EmailHTTPEnabled
cfg.Notification.SMTPEnabled = input.EmailSMTPEnabled

View File

@ -0,0 +1,137 @@
.dns-accounts-panel {
padding: 24px;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #fff;
}
.dns-accounts-heading,
.dns-pagination,
.dns-page-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.dns-accounts-heading {
margin-bottom: 24px;
}
.dns-accounts-heading h2 {
margin: 0 0 8px;
color: #20314f;
font-size: 26px;
}
.dns-accounts-count,
.dns-muted,
.dns-empty {
color: #64748b;
font-size: 14px;
}
.dns-refresh,
.dns-pagination button,
.dns-pagination select {
padding: 9px 12px;
border: 1px solid #d5ddea;
border-radius: 10px;
background: #fff;
color: #32405a;
font: inherit;
}
.dns-refresh,
.dns-pagination button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.5;
}
button:focus-visible,
select:focus-visible,
.dns-table-scroll:focus-visible {
outline: 2px solid #315aaa;
outline-offset: 3px;
}
.dns-table-scroll {
overflow-x: auto;
}
.dns-accounts-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 14px;
}
.dns-accounts-table th,
.dns-accounts-table td {
padding: 16px 12px;
border-bottom: 1px solid #e8edf4;
overflow-wrap: anywhere;
}
.dns-accounts-table th {
color: #66758d;
background: #f7f9fc;
font-weight: 600;
white-space: nowrap;
}
.dns-identifier {
font-family: ui-monospace, monospace;
}
.dns-status {
display: inline-block;
padding: 5px 9px;
border-radius: 7px;
background: #fff4db;
color: #805300;
white-space: nowrap;
}
.dns-status.verified {
background: #eaf7ef;
color: #216044;
}
.dns-error {
padding: 14px;
border-radius: 10px;
background: #fff0f0;
color: #a92e2e;
}
.dns-empty {
margin: 0;
padding: 40px 12px;
text-align: center;
}
.dns-pagination {
flex-wrap: wrap;
margin-top: 20px;
font-size: 13px;
color: #52627b;
}
.dns-pagination select {
margin: 0 5px;
}
@media (max-width: 760px) {
.dns-accounts-panel {
padding: 16px;
}
.dns-accounts-table {
min-width: 640px;
}
}

View File

@ -0,0 +1,60 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
export function dnsVerificationLabel(item) {
if (item.credential_state === "unavailable") return "凭据不可读取";
if (item.credential_state === "invalid") return "凭据配置不完整";
return item.verification_state === "verified" ? "已验证" : "待验证";
}
export function useDNSAccounts() {
const accounts = ref([]);
const loading = ref(false);
const error = ref("");
const pageSize = ref(10);
const page = ref(1);
const pageCount = computed(() => Math.max(1, Math.ceil(accounts.value.length / pageSize.value)));
const visibleAccounts = computed(() => accounts.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value));
let requestVersion = 0;
let controller;
async function loadAccounts() {
const version = ++requestVersion;
controller?.abort();
controller = new AbortController();
loading.value = true;
error.value = "";
accounts.value = [];
page.value = 1;
try {
const response = await fetch("/api/v1/dns/accounts", {
credentials: "include",
cache: "no-store",
signal: controller.signal,
});
if (response.status === 401) throw new Error("登录会话已过期,请重新登录");
if (response.status === 403) throw new Error("当前账号没有查看 DNS 账户的权限");
if (!response.ok) throw new Error("DNS 账户加载失败,请稍后重试");
const data = await response.json();
if (!Array.isArray(data.items) || !data.items.every((item) => item && typeof item.id === "string")) {
throw new Error("DNS 账户响应格式不正确,请刷新重试");
}
if (version === requestVersion) accounts.value = data.items;
} catch (reason) {
if (version === requestVersion) error.value = reason.message || "DNS 账户加载失败,请稍后重试";
} finally {
if (version === requestVersion) loading.value = false;
}
}
function changePageSize() {
page.value = 1;
}
onMounted(loadAccounts);
onBeforeUnmount(() => {
requestVersion += 1;
controller?.abort();
});
return { accounts, loading, error, pageSize, page, pageCount, visibleAccounts, loadAccounts, changePageSize };
}

View File

@ -0,0 +1,75 @@
<script setup>
import { watch } from "vue";
import { dnsVerificationLabel, useDNSAccounts } from "./DNSAccounts.js";
const props = defineProps({ refreshKey: { type: Object, default: null } });
const {
accounts, loading, error, pageSize, page, pageCount,
visibleAccounts, loadAccounts, changePageSize,
} = useDNSAccounts();
watch(() => props.refreshKey, loadAccounts);
</script>
<template>
<section class="dns-accounts-panel" :aria-busy="loading">
<header class="dns-accounts-heading">
<div>
<h2>DNS 账户管理</h2>
<span class="dns-accounts-count">{{ loading ? '正在加载' : error ? '加载失败' : `共 ${accounts.length} 个账号` }}</span>
</div>
<button class="dns-refresh" type="button" :disabled="loading" @click="loadAccounts">
{{ loading ? '加载中…' : '刷新列表' }}
</button>
</header>
<p v-if="error" class="dns-error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="dns-empty" role="status">正在读取已配置的 DNS 账号…</p>
<p v-else-if="accounts.length === 0" class="dns-empty">暂无可查看的已配置 DNS 账号</p>
<template v-else>
<div class="dns-table-scroll" tabindex="0" role="region" aria-label="DNS 账户列表">
<table class="dns-accounts-table">
<thead>
<tr>
<th scope="col">账户名称</th>
<th scope="col">DNS 服务商</th>
<th scope="col" title="仅展示脱敏后的公开账号标识,不展示密钥或令牌">账号标识</th>
<th scope="col" title="归属来自已保存的用户记录;历史配置未记录归属时显示未分配">归属用户</th>
<th scope="col" title="已验证表示保存配置时通过验证,不代表此刻云端权限仍然有效">验证状态</th>
</tr>
</thead>
<tbody>
<tr v-for="account in visibleAccounts" :key="account.id">
<td>{{ account.name }}</td>
<td>{{ account.provider_name }}</td>
<td class="dns-identifier">{{ account.account_identifier }}</td>
<td :class="{ 'dns-muted': account.owner_state !== 'assigned' }">{{ account.owner_username }}</td>
<td>
<span
class="dns-status"
:class="{ verified: account.credential_state === 'available' && account.verification_state === 'verified' }"
>{{ dnsVerificationLabel(account) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="dns-pagination">
<label>
每页
<select v-model.number="pageSize" aria-label="DNS 账户每页条数" @change="changePageSize">
<option v-for="size in [5, 10, 15, 30]" :key="size" :value="size">{{ size }}</option>
</select>
条
</label>
<div class="dns-page-actions">
<button type="button" :disabled="page <= 1" @click="page -= 1">上一页</button>
<span>第 {{ page }} / {{ pageCount }} 页</span>
<button type="button" :disabled="page >= pageCount" @click="page += 1">下一页</button>
</div>
</footer>
</template>
</section>
</template>
<style scoped src="./DNSAccounts.css"></style>

View File

@ -10,7 +10,7 @@ import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../
test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => {
const navigation = useDashboardNavigation(ref({ role: "super_admin" }));
assert.equal(navigation.activeSection.value, "overview");
assert.equal(navigation.sections.value.length, 6);
assert.equal(navigation.sections.value.length, 7);
for (const section of navigation.sections.value) {
navigation.selectSection(section.id);
assert.equal(navigation.activeSection.value, section.id);
@ -31,7 +31,7 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", ()
assert.equal(navigation.activeSection.value, "users");
user.value = { role: "admin" };
assert.equal(navigation.activeSection.value, "users");
assert.equal(navigation.sections.value.length, 6);
assert.equal(navigation.sections.value.length, 7);
user.value = null;
assert.equal(navigation.activeSection.value, "overview");
assert.equal(navigation.sections.value.length, 5);
@ -41,7 +41,7 @@ test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const stylesheet = await readFile(new URL("./DashboardLayout.css", import.meta.url), "utf8");
for (const id of ["overview", "activity", "domains", "servers", "keys", "users"]) {
for (const id of ["overview", "activity", "domains", "servers", "keys", "dns-accounts", "users"]) {
assert.ok(template.includes(`v-if="activeSection === '${id}'"`));
}
assert.match(template, /:key="activeSection"/);
@ -97,6 +97,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
domains: "托管域名",
servers: "部署服务器",
keys: "SSH 私钥",
"dns-accounts": "DNS 账户管理",
users: "用户管理",
};
for (const section of navigation.sections.value) {
@ -109,6 +110,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
app.component(name, { render: () => null });
}
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
app.component("DeploymentResult", { render: () => null });
const warnings = [];
app.config.warnHandler = (message) => warnings.push(message);

View File

@ -1,5 +1,6 @@
<script setup>
import UserManagement from "../UserManagement/UserManagement.vue";
import DNSAccounts from "../DNSAccounts/DNSAccounts.vue";
import SettingsModal from "./SettingsModal.vue";
import DomainModal from "./DomainModal.vue";
import ServerModal from "./ServerModal.vue";
@ -322,6 +323,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
</div>
<UserManagement v-if="activeSection === 'users'" />
<DNSAccounts v-if="activeSection === 'dns-accounts'" :refresh-key="settings" />
<section v-if="activeSection === 'activity'" class="panel operations-panel">
<div class="panel-heading">

View File

@ -6,13 +6,14 @@ export const dashboardSections = [
{ id: "domains", label: "域名配置", group: "证书与部署", hint: "管理域名、申请证书和执行部署" },
{ id: "servers", label: "部署服务器", group: "证书与部署", hint: "配置部署目标及 SSH 认证方式" },
{ id: "keys", label: "SSH 私钥", group: "证书与部署", hint: "管理加密保存的 SSH 私钥" },
{ id: "dns-accounts", label: "DNS 账户管理", group: "证书与部署", hint: "查看已配置的 DNS 账号及归属用户,凭据标识脱敏显示" },
{ id: "users", label: "用户管理", group: "系统管理", hint: "管理登录账号、角色与使用状态" },
];
export function useDashboardNavigation(currentUser) {
const selectedSection = ref("overview");
const sections = computed(() => dashboardSections.filter(
(section) => section.id !== "users" || ["super_admin", "admin"].includes(currentUser.value?.role),
(section) => !["users", "dns-accounts"].includes(section.id) || ["super_admin", "admin"].includes(currentUser.value?.role),
));
const activeSection = computed(() => sections.value.some(
(section) => section.id === selectedSection.value,

View File

@ -46,6 +46,11 @@ defineEmits(["select", "settings"]);
<circle cx="8" cy="8" r="5" />
<path d="m12 12 9 9m-6-6 3-3m0 6 3-3" />
</g>
<g v-else-if="section.id === 'dns-accounts'">
<rect x="3" y="4" width="18" height="16" rx="3" />
<circle cx="9" cy="10" r="2" />
<path d="M5 17a4 4 0 0 1 8 0m2-8h4m-4 4h4" />
</g>
<g v-else>
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-2a8 8 0 0 1 16 0v2" />