接通域名 DNS 账户选择并记录三库复审结果

This commit is contained in:
xuans 2026-10-04 06:53:43 +08:00
parent 122291fe43
commit de2ce3ae8a
14 changed files with 656 additions and 24 deletions

View File

@ -1,5 +1,25 @@
# DNS 多账户整改任务清单
## 2026年10月4日06:51 域名账户选择与 Linux 复审结果
- [✓] 第一台干净 `412c51f` 的 `0630` 单元 06:45:57 结束,inactive/success、退出码零;Linux 全量、vet 通过。单轮竞态仓储 17.076 秒、HTTP 23.331 秒;SQLite、MySQL、PostgreSQL 项目目录均实际运行通过,PostgreSQL 非跳过。
- [x] 域名表单读取实时绑定和账户目录,显示账户、服务商、用户归属;只选同项目已启用、验证通过且凭据可用的独立账户。原账户不可用时保留真实绑定,不擅自清空。
- [x] 新建与编辑依次保存资料、显式 DNS 绑定、服务器关联;绑定使用真实旧令牌和选定账户版本。不同服务商切换由绑定事务更新元数据,解除必须显式选择,切换 HTTP-01 需先解除保存。
- [x] 绑定失败停止后续保存,409 或网络不确定不自动重放、不使用系统凭据兜底;已创建的域名或成功绑定不因后续服务器关联失败重复创建、绑定。重新读取后核对真实状态再操作。
- [✓] 新增 24 项逻辑、2 项渲染回归;相关 46 项通过,前端全量 336 项零失败零跳过,生产构建 103 模块及差异检查通过。覆盖固定目标、跨项目、异常响应、迟到响应、卸载和保存中重复操作。
- [~] 最新域名页面尚未上线及浏览器验收。执行租约、真实签发/续期/重试、旧账户幂等导入、安全删除回收与生产门禁仍未完成,P1-03 保持进行中。
## 2026年10月4日06:44 域名表单接入任务
沿用上方总目标及下方已确认需求,不缩减独立账户签发、续期和重试范围。
- [✓] 修改前检查点 `122291f` 已提交推送;首次鉴权失败后重推成功,未跟踪文件 `=` 保留。
- [~] 第一台干净 `412c51f` 的 `scert-dns-ui-20261004-0630.service` 已实际启动,执行 Linux 全量、vet 和三库目录及接口竞态;观察时 active/running,不提前计为通过。
- [~] 域名表单读取真实 DNS 绑定、按项目选择已启用且验证通过的独立账户,并保留历史配置兼容入口。
- [ ] 接通新建及编辑保存,固定目标、绑定令牌和账户版本;部分成功不重复创建,冲突或结果不确定时必须刷新,禁止静默降级。
- [ ] 覆盖迟到响应、卸载、跨项目、轮换、禁用、绑定失败、服务器关联失败后的重试及页面渲染。
- [ ] 继续执行租约、旧账户导入、安全删除回收与真实签发续期;当前不发布过渡版本,不关闭 P1-03。
## 2026年10月4日06:28 页面生命周期开发结果
- [✓] 第一台 `0610` 单元已 inactive/success、退出码零;`c80530f` 单轮三库竞态通过:数据库 81.666 秒、仓储 37.304 秒、证书 1.032 秒、HTTP 22.561 秒。包含历史排序规则与错误列排序规则用例,修复获得实库证据。不是原 `0555` 三轮通过。

View File

@ -1,5 +1,15 @@
# Linux 生产流程测试记录
## 2026年10月4日06:51 DNS 页面权限干净源码复审
- 第一台 `8.137.23.95`,源码 `/var/tmp/scert-dns-ui-20261004-0630/source`,真实 Git 干净签出 `412c51f5f0cf466f9d1ff01a09e23dd02e116385`;运行后工作区仍干净。
- 最后增量 bundle 本机及第一台 SHA-256 一致:`fc7441a3792813358c12b690ba5eb0a1b0d4cfcfee9ab2c3ed0bb3cb970cd47c`。原等待密码的 SCP 连接关闭,重新密码认证上传成功,未据观察超时重启测试。
- `scert-dns-ui-20261004-0630.service` 从 06:39:22 至 06:45:57,终态 inactive/dead、Result=success、ExecMainStatus=0。使用 nobody、内存 1200M、CPU 150%、本机离线工具链,不创建辅助脚本。
- `go test -p 1 -count=1 -timeout=10m ./...` 及 `go vet -p 1 ./...` 通过。该全量未启用外部数据库,不能据此声称所有三库用例执行。
- 随后开启隔离 MySQL/PostgreSQL,以 `-race -p 1 -count=1 -timeout=10m -v` 执行仓储和 HTTP 的 `Test(ProjectDirectory|ManagedDNSAccount|AuthSessionsSupportOptionalEmail|AdminAPIRejectsAuthenticatedRegularUser)` 专项;仓储 17.076 秒、HTTP 23.331 秒。SQLite 目录 1.26 秒、MySQL 12.88 秒、PostgreSQL 1.90 秒均 PASS,不是跳过或三轮结果。
- 完整日志 `/var/tmp/scert-dns-ui-20261004-0630/review.log`;本轮不清理业务数据库、不升级正式服务、不操作第二台及发信配置。
- 后续域名选择前端增量:本地相关 46 项、全量 336 项及 Vite 构建 103 模块通过;尚无最新页面的正式部署与浏览器证据,不能将本节后端结果套用为新增页面的实机验收。
## 2026年10月4日06:28 磁盘故障终态、绑定复跑和页面开发
### 第一台绑定修复实库结果

View File

@ -1,5 +1,11 @@
# Scert 开发任务清单
## 2026年10月4日06:51 开发与复审进展
- [x] 独立 DNS 账户选择已接入域名新建和编辑,显示服务商及归属;显式解除、实时绑定读取、版本冲突、跨项目过滤和部分成功恢复完成编码。
- [✓] 前端新增 26 项、全量 336 项及构建通过;第一台 `412c51f` 后端全量、vet 和单轮三库目录及接口竞态均结束通过。
- [~] 执行租约、签发续期重试、旧账户导入、安全删除回收与实际浏览器验收尚未完成;完整开发和生产验收仍未完成,不发布过渡功能为最终版本。
## 2026年10月4日06:28 DNS 页面开发进度
- [x] 独立账户页面生命周期及项目权限提示已接入,表格内滚动、原生弹窗和双列字段;凭据无持久化及回填,写入冲突不自动重试。

View File

@ -1,5 +1,12 @@
# Scert 整改任务执行清单
## 2026年10月4日06:51 DNS 域名配置接入
- [✓] 第一台 `412c51f` 干净源码全量、vet 和三库目录及接口单轮竞态通过,`0630` 单元退出零;不是三轮结果。
- [x] 域名表单账户选择及显式绑定、解除完成,保留原不可用绑定;冲突和不确定结果要求重新读取,部分成功不重复创建或绑定。
- [✓] 前端 336 项与构建通过,新增 26 项逻辑及渲染回归;最新页面未发布,浏览器验收待完成。
- [~] 执行租约、签发续期重试、历史导入及安全回收继续开发;P1-03 和总体生产验收不关闭。
## 2026年10月4日06:28 DNS 页面生命周期
- [✓] 第一台 `0610` 修复版单轮三库竞态四包通过;原 `0555` 磁盘不足失败已记录,清理范围仅已停止的隔离 MySQL 数据,日志保留。

View File

@ -1,5 +1,14 @@
# Scert 整改、合并与优化清单
## 2026年10月4日06:51 最新执行进度
- [✓] 第一台干净 `412c51f` Linux 全量、vet 和单轮三库项目目录及 HTTP 权限竞态全部结束通过;`0630` 单元 success、退出零,PostgreSQL 新增目录用例实际执行通过。
- [x] P1-03 域名配置已接入独立 DNS 账户选择、用户归属展示、版本与绑定令牌校验、显式解除及部分保存失败恢复;新建后不重复创建,绑定不确定时不静默改用系统凭据。
- [✓] 最新前端全量 336 项与构建通过;26 项新增逻辑及渲染回归。真实浏览器和最新正式产物尚未验证,不以单元测试代替。
- [~] 继续执行租约及实际签发、自动续期和重试接入;旧账户导入、安全删除回收、三库首次 Web、Smail 发信与重置、公共域名等生产门禁仍未完成。
第一台仍承担主程序测试,第二台只做证书推送。未升级正式服务,未改变域名解析、发信配置或业务数据库;本轮不处理 Windows。详细证据见《DNS多账户整改任务清单》和《Linux生产流程测试记录》。
## 2026年10月4日06:28 DNS 页面与实库进度
- [✓] `c80530f` 第一台 `0610` 单轮三库竞态四包通过,MySQL 历史排序规则修复获得实库证据。`0555` 先前因磁盘不足失败;仅清理已终止隔离 MySQL 数据目录,保留日志和生产数据,不改写失败记录。

View File

@ -1,5 +1,13 @@
# 核心能力任务规格
## 2026年10月4日06:51 域名 DNS 账户交互契约
- 新建及编辑域名读取 `/api/v1/dns/accounts`;编辑还读取 `/api/v1/domains/:id/dns-account`,列表缓存不能替代真实绑定令牌。
- 账户筛选要求当前项目、独立账户、启用、验证通过、凭据可用及合法版本;已有不可用账户保留绑定并提示,不自动变更。
- 保存顺序为域名资料、显式账户绑定、服务器关联;账户绑定请求包含 `dns_account_id`、`expected_binding_id`、`account_revision`,固定打开的域名与提交瞬间账户版本。
- 绑定冲突、异常响应或网络结果不明禁止自动重放,先重新读取核对;已成功创建或绑定不因后续步骤失败重复操作。HTTP-01 切换前须显式解除并保存,避免静默降级。
- 前端全量 336 项及构建通过,仅证明交互契约回归;独立账户执行租约尚未接入,不据此宣称实际签发、续期或生产验收完成。
## 2026年10月4日06:28 DNS 页面权限契约
- `GET /api/v1/projects` 增加 `can_manage_dns`:超级管理员、当前项目管理员或 DNS 管理员为真;这是界面提示,不替代每次写入的实时授权。

View File

@ -6,6 +6,7 @@ import "./DashboardLogout.test.js";
import "./SettingsAccess.test.js";
import "./DomainModal.test.js";
import "./DomainForm.test.js";
import "./DomainDNSAccount.test.js";
import "../DNSAccounts/DNSAccounts.test.js";
import test from "node:test";
import { nextTick, ref } from "vue";
@ -26,7 +27,7 @@ test("管理页面使用拆分后的域名表单与同项目服务器候选", as
if (path === "/api/v1/servers") return Response.json({ items: [server, { id: "other", project_id: "other" }] });
return Response.json({ items: [] });
});
state.openCreate();
await state.openCreate();
assert.deepEqual(state.domainServerOptions.value, [server]);
state.form.value.name = item.name;
state.form.value.server_ids = [server.id];

View File

@ -0,0 +1,187 @@
import { computed, onBeforeUnmount, ref } from "vue";
const validID = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
function validBinding(item, domainID, projectID) {
return item && item.domain_id === domainID && item.project_id === projectID &&
((item.id === "" && item.dns_account_id === "") ||
(validID(item.id) && validID(item.dns_account_id)));
}
export function useDomainDNSAccount({ fetchAdmin, form, saving }) {
const dnsAccounts = ref([]);
const dnsAccountID = ref("");
const dnsBindingLoading = ref(false);
const dnsBindingReady = ref(false);
const dnsBindingError = ref("");
const dnsBound = ref(false);
let generation = 0;
let controller;
let target = null;
let binding = null;
let boundProvider = "";
const domainDNSAccountOptions = computed(() => dnsAccounts.value.filter((account) =>
account.project_id === target?.projectID && account.managed === true &&
validID(account.id) && validID(account.provider) &&
Number.isInteger(account.revision) && account.revision > 0 && account.revision <= 2147483647 &&
account.status === "active" && account.verification_state === "verified" &&
account.credential_state === "available",
));
const dnsUnavailableSelection = computed(() => Boolean(dnsAccountID.value) &&
!domainDNSAccountOptions.value.some((account) => account.id === dnsAccountID.value));
function cancel() {
generation += 1;
controller?.abort();
controller = null;
dnsBindingLoading.value = false;
dnsBindingReady.value = false;
dnsAccounts.value = [];
dnsAccountID.value = "";
dnsBindingError.value = "";
dnsBound.value = false;
target = null;
binding = null;
}
async function request(path, options = {}) {
let response;
try {
response = await fetchAdmin(path, { cache: "no-store", ...options });
} catch {
throw new Error("网络读取失败,请重新读取 DNS 账户后核对");
}
if (!response.ok) {
const messages = {
401: "登录已失效,请重新登录",
403: "无权读取或变更 DNS 账户绑定,请刷新权限后重新打开",
409: "绑定、账户版本或任务状态已变化,请重新读取后核对,不会自动重试",
422: "当前验证方式或账户状态不允许绑定,请重新读取后核对",
};
throw new Error(messages[response.status] || "DNS 账户请求失败,请重新读取后核对");
}
try {
return await response.json();
} catch {
throw new Error("DNS 账户响应格式不正确,请重新读取后核对");
}
}
async function reloadDomainDNS() {
if (saving.value || !target) return;
controller?.abort();
controller = new AbortController();
const signal = controller.signal;
const current = ++generation;
const expected = { ...target };
dnsBindingLoading.value = true;
dnsBindingReady.value = false;
dnsBindingError.value = "";
dnsAccounts.value = [];
try {
const [accounts, result] = await Promise.all([
request("/api/v1/dns/accounts", { signal }),
expected.domainID
? request(`/api/v1/domains/${expected.domainID}/dns-account`, { signal })
: Promise.resolve({ id: "", dns_account_id: "", domain_id: "", project_id: expected.projectID }),
]);
if (current !== generation) return;
if (!Array.isArray(accounts?.items) || !validBinding(result, expected.domainID, expected.projectID)) {
throw new Error("DNS 账户或绑定响应不完整,请重新读取");
}
binding = { ...result };
dnsAccounts.value = accounts.items.filter((item) => item && typeof item === "object");
dnsAccountID.value = binding.dns_account_id;
dnsBound.value = Boolean(binding.id);
const account = dnsAccounts.value.find((item) => item.id === binding.dns_account_id);
// 重新读取后以实际绑定服务商为准;不把用户尚未保存的选择当成现有绑定。
if (account && validID(account.provider)) boundProvider = account.provider;
if (binding.id) form.value.dns_provider = boundProvider;
dnsBindingReady.value = true;
} catch (error) {
if (current !== generation) return;
binding = null;
dnsBindingError.value = error instanceof SyntaxError || error?.name === "TypeError"
? "读取 DNS 账户失败,请重新读取;不会清空原绑定"
: error.message || "读取 DNS 账户失败,请重新读取";
} finally {
if (current === generation) dnsBindingLoading.value = false;
}
}
function open(item = {}) {
cancel();
target = { domainID: item.id || "", projectID: item.project_id ?? form.value.project_id };
boundProvider = item.dns_provider || "";
return reloadDomainDNS();
}
function prepare(payload) {
if (!dnsBindingReady.value || dnsBindingLoading.value || !binding) {
throw new Error("请先成功读取 DNS 账户和绑定,再保存配置");
}
if (form.value.project_id !== target.projectID) {
throw new Error("域名项目已变化,请关闭后重新打开表单");
}
if (payload.challenge_type === "http-01" && (binding.id || dnsAccountID.value)) {
throw new Error("请先在 DNS 验证方式下解除账户绑定并保存,再切换 HTTP-01");
}
const changed = dnsAccountID.value !== binding.dns_account_id;
const account = domainDNSAccountOptions.value.find((item) => item.id === dnsAccountID.value);
if (changed && dnsAccountID.value && !account) {
throw new Error("请选择当前项目内已启用且验证通过的 DNS 账户");
}
if (binding.id) payload.dns_provider = boundProvider;
else if (account) payload.dns_provider = account.provider;
return Object.freeze({
generation, domainID: target.domainID, projectID: target.projectID, changed,
accountID: dnsAccountID.value, revision: account?.revision || 0,
expectedID: binding.id, provider: account?.provider || "",
});
}
async function persist(snapshot, domainID) {
if (snapshot.generation !== generation) return null;
if (!validID(domainID) || (snapshot.domainID && domainID !== snapshot.domainID)) {
throw new Error("域名保存目标不一致,请关闭表单后核对");
}
target.domainID = domainID;
if (!snapshot.changed) return null;
try {
const result = await request(`/api/v1/domains/${domainID}/dns-account`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dns_account_id: snapshot.accountID,
expected_binding_id: snapshot.expectedID,
account_revision: snapshot.revision,
}),
});
if (snapshot.generation !== generation) return null;
if (!validBinding(result, domainID, snapshot.projectID) || result.dns_account_id !== snapshot.accountID ||
(snapshot.accountID && result.id === snapshot.expectedID)) {
throw new Error("DNS 绑定保存响应不完整,请重新读取后核对");
}
binding = { ...result };
dnsAccountID.value = result.dns_account_id;
dnsBound.value = Boolean(result.id);
boundProvider = snapshot.provider;
form.value.dns_provider = boundProvider;
return { dns_account_id: result.dns_account_id, dns_binding_id: result.id, dns_provider: boundProvider };
} catch {
if (snapshot.generation !== generation) return null;
// 写入结果可能已提交,不能用旧令牌自动重试,也不能退回系统账户继续保存。
dnsBindingReady.value = false;
dnsBindingError.value = "DNS 绑定保存未确认,可能存在权限、版本或在途任务冲突;请重新读取后核对,不会自动重试";
throw new Error(dnsBindingError.value);
}
}
onBeforeUnmount(cancel);
return {
dnsAccountID, dnsBindingLoading, dnsBindingReady, dnsBindingError, dnsBound,
domainDNSAccountOptions, dnsUnavailableSelection, reloadDomainDNS,
open, cancel, prepare, persist,
};
}

View File

@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ref } from "vue";
import { useDomainForm } from "./DomainForm.js";
import { deferred, mountComposable } from "../../test-support/mount.js";
const account = {
id: "account-one", project_id: "default", provider: "aliyun", provider_name: "阿里云 DNS",
name: "生产账户", owner_username: "甲用户", managed: true, status: "active",
verification_state: "verified", credential_state: "available", revision: 3,
};
const item = {
id: "domain-one", project_id: "default", name: "one.example.test", sans: [],
certificate_type: "single", auto_renew: true, acme_environment: "staging",
challenge_type: "dns-01", dns_provider: "aliyun",
};
const emptyBinding = { id: "", domain_id: item.id, project_id: "default", dns_account_id: "" };
const originalBinding = { ...emptyBinding, id: "binding-old", dns_account_id: account.id };
async function mountForm(t, { accounts = [account], binding = emptyBinding, intercept = () => null } = {}) {
const requests = [];
let currentBinding = { ...binding };
const deps = {
fetchAdmin: (...args) => fetch(...args), domains: ref([]), servers: ref([]),
domainServers: ref({}), notice: ref(""), loadData: async () => {},
};
const mounted = await mountComposable(t, () => useDomainForm(deps), async (path, options = {}) => {
requests.push({ path, ...options });
const intercepted = intercept(path, options);
if (intercepted) return intercepted;
if (path === "/api/v1/dns/accounts") return Response.json({ items: accounts });
if (path.endsWith("/dns-account")) {
if (options.method) {
const body = JSON.parse(options.body);
currentBinding = {
...emptyBinding, id: body.dns_account_id ? "binding-new" : "", dns_account_id: body.dns_account_id,
};
}
return Response.json(currentBinding);
}
if (path.endsWith("/servers")) return Response.json({ items: [] });
return Response.json({ ...item, ...JSON.parse(options.body) });
});
return { ...mounted, deps, requests, writes: () => requests.filter((request) => request.method) };
}
async function create(state) {
await state.openCreate();
state.form.value.name = item.name;
state.dnsAccountID.value = account.id;
}
test("新域名先保存资料再显式绑定账户,提交真实版本和空绑定令牌", async (t) => {
const { state, deps, writes } = await mountForm(t);
await create(state);
await state.saveDomain();
assert.deepEqual(writes().map((request) => request.path), [
"/api/v1/domains", `/api/v1/domains/${item.id}/dns-account`, `/api/v1/domains/${item.id}/servers`,
]);
assert.equal(JSON.parse(writes()[0].body).dns_provider, "aliyun");
assert.deepEqual(JSON.parse(writes()[1].body), {
dns_account_id: account.id, expected_binding_id: "", account_revision: 3,
});
assert.equal(deps.domains.value[0].dns_binding_id, "binding-new");
assert.equal(state.showForm.value, false);
});
test("编辑使用真实绑定令牌,更换服务商时先保留旧服务商再事务绑定", async (t) => {
const second = { ...account, id: "account-two", provider: "cloudflare", revision: 8 };
const { state, deps, writes } = await mountForm(t, { accounts: [account, second], binding: originalBinding });
await state.openEdit({ ...item, dns_binding_id: "stale-list" });
state.dnsAccountID.value = second.id;
await state.saveDomain();
assert.equal(JSON.parse(writes()[0].body).dns_provider, "aliyun");
assert.deepEqual(JSON.parse(writes()[1].body), {
dns_account_id: second.id, expected_binding_id: originalBinding.id, account_revision: 8,
});
assert.equal(deps.domains.value[0].dns_provider, "cloudflare");
});
test("账户按项目、状态、验证结果、凭据可用性及真实独立标识筛选", async (t) => {
const invalid = [
{ project_id: "other" }, { managed: false }, { status: "disabled" },
{ verification_state: "unverified" }, { credential_state: "missing" },
{ revision: 0 }, { revision: 2147483648 }, { provider: "../bad" }, { id: "../bad" },
].map((overrides, index) => ({ ...account, id: `invalid-${index}`, ...overrides }));
const { state, writes } = await mountForm(t, { accounts: [null, ...invalid, account] });
await create(state);
assert.deepEqual(state.domainDNSAccountOptions.value.map((entry) => entry.id), [account.id]);
for (const entry of invalid) {
state.dnsAccountID.value = entry.id;
await state.saveDomain();
assert.match(state.formError.value, /当前项目内/);
}
assert.equal(writes().length, 0);
});
test("停用的原账户保留绑定且不重新使用旧验证结果,可以显式解除", async (t) => {
const { state, writes } = await mountForm(t, { accounts: [{ ...account, status: "disabled" }], binding: originalBinding });
await state.openEdit(item);
assert.equal(state.dnsAccountID.value, account.id);
assert.equal(state.dnsUnavailableSelection.value, true);
state.dnsAccountID.value = "";
await state.saveDomain();
assert.deepEqual(JSON.parse(writes()[1].body), {
dns_account_id: "", expected_binding_id: originalBinding.id, account_revision: 0,
});
});
test("已有绑定切换 HTTP 验证必须先解除,不能在同次保存中静默回退", async (t) => {
const { state, writes } = await mountForm(t, { binding: originalBinding });
await state.openEdit(item);
state.dnsAccountID.value = "";
state.form.value.challenge_type = "http-01";
await state.saveDomain();
assert.match(state.formError.value, /先.*解除账户绑定/);
assert.equal(writes().length, 0);
});
test("无绑定 HTTP 验证保留兼容路径,不调用绑定写入", async (t) => {
const { state, writes } = await mountForm(t);
await state.openCreate();
state.form.value.name = item.name;
state.form.value.challenge_type = "http-01";
await state.saveDomain();
assert.equal(writes().length, 2);
assert.equal(JSON.parse(writes()[0].body).dns_provider, "");
});
for (const failure of ["network", "invalid-json", "wrong-domain", "wrong-project", "half-binding", "forbidden"]) {
test(`绑定读取失败不清空、不允许保存:${failure}`, async (t) => {
const { state, writes } = await mountForm(t, { intercept: (path) => {
if (!path.endsWith("/dns-account")) return null;
if (failure === "network") return Promise.reject(new Error("secret-test-only"));
if (failure === "invalid-json") return new Response("secret-test-only");
if (failure === "forbidden") return Response.json({ error: "secret-test-only" }, { status: 403 });
return Response.json({ ...originalBinding,
...(failure === "wrong-domain" ? { domain_id: "other-domain" } : {}),
...(failure === "wrong-project" ? { project_id: "other" } : {}),
...(failure === "half-binding" ? { dns_account_id: "" } : {}),
});
} });
await state.openEdit(item);
assert.equal(state.dnsBindingReady.value, false);
assert.doesNotMatch(state.dnsBindingError.value, /secret-test-only/);
await state.saveDomain();
assert.equal(writes().length, 0);
});
}
for (const failure of ["network", "conflict", "forbidden", "server", "invalid-json", "wrong-domain", "wrong-account"]) {
test(`绑定写入失败停止后续关联且禁止盲目重放:${failure}`, async (t) => {
const { state, writes } = await mountForm(t, { intercept: (path, options) => {
if (!path.endsWith("/dns-account") || !options.method) return null;
if (failure === "network") return Promise.reject(new Error("secret-test-only"));
if (failure === "invalid-json") return new Response("secret-test-only");
if (["conflict", "forbidden", "server"].includes(failure)) {
return Response.json({ error: "secret-test-only" }, { status: { conflict: 409, forbidden: 403, server: 503 }[failure] });
}
return Response.json({ ...originalBinding,
...(failure === "wrong-domain" ? { domain_id: "other-domain" } : { dns_account_id: "other-account" }),
});
} });
await create(state);
await state.saveDomain();
assert.equal(writes().length, 2);
assert.equal(state.dnsBindingReady.value, false);
assert.match(state.formError.value, /域名资料已保存.*DNS 账户绑定/);
assert.doesNotMatch(state.formError.value, /secret-test-only/);
await state.saveDomain();
assert.equal(writes().length, 2);
});
}
test("绑定已成功而服务器关联失败时只重试服务器,不重新创建或绑定", async (t) => {
let calls = 0;
const { state, writes } = await mountForm(t, { intercept: (path, options) => {
if (!path.endsWith("/servers") || !options.method) return null;
calls += 1;
return calls === 1 ? Response.json({ error: "关联冲突" }, { status: 409 }) : null;
} });
await create(state);
await state.saveDomain();
assert.equal(writes().length, 3);
assert.equal(state.showForm.value, true);
await state.saveDomain();
assert.equal(writes().length, 4);
assert.ok(writes()[3].path.endsWith("/servers"));
assert.equal(state.showForm.value, false);
});
test("冲突后重新读取实际绑定,确认状态而不是自动重放旧选择", async (t) => {
let conflict = true;
const { state, writes } = await mountForm(t, { intercept: (path, options) => {
if (!path.endsWith("/dns-account")) return null;
if (options.method && conflict) {
conflict = false;
return Response.json({}, { status: 409 });
}
if (!options.method) return Response.json(originalBinding);
return null;
} });
await create(state);
await state.saveDomain();
await state.reloadDomainDNS();
assert.equal(state.dnsBindingReady.value, true);
assert.equal(state.dnsAccountID.value, account.id);
await state.saveDomain();
assert.equal(writes().filter((request) => request.path.endsWith("/dns-account")).length, 1);
assert.equal(writes().filter((request) => request.method === "POST").length, 1);
});
test("切换或卸载后迟到的账户响应不能恢复旧表单状态", async (t) => {
const pending = deferred();
let reads = 0;
const { state, unmount, writes } = await mountForm(t, { intercept: (path) => {
if (path !== "/api/v1/dns/accounts") return null;
reads += 1;
return reads === 1 ? pending.promise : null;
} });
const opening = state.openEdit(item);
assert.equal(state.dnsBindingLoading.value, true);
state.closeForm();
await state.openCreate();
pending.resolve(Response.json({ items: [{ ...account, id: "late-account" }] }));
await opening;
assert.deepEqual(state.domainDNSAccountOptions.value.map((entry) => entry.id), [account.id]);
assert.equal(state.dnsAccountID.value, "");
unmount();
assert.equal(state.dnsBindingReady.value, false);
assert.equal(writes().length, 0);
});
test("保存中固定目标账户和版本,拒绝双击、关闭、重读和切换", async (t) => {
const pending = deferred();
const { state, writes } = await mountForm(t, { intercept: (path) => path === "/api/v1/domains" ? pending.promise : null });
await create(state);
const saving = state.saveDomain();
state.dnsAccountID.value = "forged-account";
state.editingId.value = "forged-domain";
state.closeForm();
await state.openCreate();
await state.reloadDomainDNS();
await state.saveDomain();
assert.equal(writes().length, 1);
pending.resolve(Response.json(item));
await saving;
assert.equal(writes()[1].path, `/api/v1/domains/${item.id}/dns-account`);
assert.equal(JSON.parse(writes()[1].body).dns_account_id, account.id);
});
test("卸载后返回的资料响应不继续写 DNS 绑定或服务器", async (t) => {
const pending = deferred();
const { state, unmount, writes } = await mountForm(t, { intercept: (path) => path === "/api/v1/domains" ? pending.promise : null });
await create(state);
const saving = state.saveDomain();
unmount();
pending.resolve(Response.json(item));
await saving;
assert.equal(writes().length, 1);
});

View File

@ -1,4 +1,5 @@
import { computed, onBeforeUnmount, ref } from "vue";
import { useDomainDNSAccount } from "./DomainDNSAccount.js";
function emptyForm() {
return {
@ -30,6 +31,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
const form = ref(emptyForm());
const editingId = ref("");
const saving = ref(false);
const dnsAccount = useDomainDNSAccount({ fetchAdmin, form, saving });
const showForm = ref(false);
const formError = ref("");
const domainBindingsLoading = ref(false);
@ -43,6 +45,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
let version = 0;
let savedPayload = "";
let createdInForm = false;
let targetID = "";
onBeforeUnmount(() => { version += 1; });
@ -50,6 +53,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
if (saving.value) return;
version += 1;
editingId.value = "";
targetID = "";
form.value = emptyForm();
formError.value = "";
savedPayload = "";
@ -58,12 +62,14 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
domainBindingsReady.value = true;
domainSaveUncertain.value = false;
showForm.value = true;
return dnsAccount.open();
}
async function openEdit(item) {
if (saving.value) return;
version += 1;
editingId.value = item.id;
targetID = item.id;
form.value = {
...emptyForm(),
project_id: item.project_id || "",
@ -82,12 +88,13 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
domainBindingsReady.value = false;
domainSaveUncertain.value = false;
showForm.value = true;
await reloadDomainBindings();
await Promise.all([reloadDomainBindings(), dnsAccount.open(item)]);
}
function closeForm() {
if (saving.value) return;
version += 1;
dnsAccount.cancel();
domainBindingsLoading.value = false;
showForm.value = false;
}
@ -95,7 +102,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
async function reloadDomainBindings() {
if (saving.value || domainBindingsLoading.value || !editingId.value) return;
const currentVersion = version;
const id = editingId.value;
const id = targetID;
domainBindingsLoading.value = true;
domainBindingsReady.value = false;
formError.value = "";
@ -152,13 +159,21 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
formError.value = "请选择当前项目内可用的服务器,每个域名最多关联一百台";
return;
}
const serialized = JSON.stringify(payload);
let dnsSnapshot;
try {
dnsSnapshot = dnsAccount.prepare(payload);
} catch (err) {
formError.value = err.message;
return;
}
let serialized = JSON.stringify(payload);
const currentVersion = version;
let saveStage = "资料";
saving.value = true;
try {
// 资料与关联不是同一事务;已确认保存的资料不因关联失败而重复创建。
if (savedPayload !== serialized) {
const existingId = editingId.value;
const existingId = targetID;
let response;
try {
response = await fetchAdmin(existingId ? `/api/v1/domains/${existingId}` : "/api/v1/domains", {
@ -181,11 +196,12 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
: result?.error || "保存域名失败");
}
if (!result || !/^[A-Za-z0-9_-]{1,128}$/.test(result.id || "") ||
!result.project_id || (existingId && result.id !== existingId)) {
result.project_id !== dnsSnapshot.projectID || (existingId && result.id !== existingId)) {
domainSaveUncertain.value = true;
throw new Error("域名保存响应不完整,请关闭表单并刷新列表确认,暂不重复提交");
}
editingId.value = result.id;
targetID = result.id;
form.value.project_id = result.project_id;
createdInForm ||= !existingId;
savedPayload = serialized;
@ -194,11 +210,21 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
else domains.value = domains.value.map((item) => item.id === result.id ? result : item);
}
if (currentVersion !== version) return;
saveStage = "DNS 账户绑定";
const dnsResult = await dnsAccount.persist(dnsSnapshot, targetID);
if (currentVersion !== version) return;
if (dnsResult) {
domains.value = domains.value.map((item) => item.id === targetID ? { ...item, ...dnsResult } : item);
payload.dns_provider = dnsResult.dns_provider;
serialized = JSON.stringify(payload);
savedPayload = serialized;
}
saveStage = "服务器关联";
const currentIds = new Set(domainServerOptions.value.map((server) => server.id));
if (serverIds.some((id) => !currentIds.has(id))) {
throw new Error("服务器所属项目或可见范围已变化,请重新选择服务器");
}
const id = editingId.value;
const id = targetID;
let response;
try {
response = await fetchAdmin(`/api/v1/domains/${id}/servers`, {
@ -215,6 +241,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
throw new Error(result?.error || "保存服务器关联结果未确认,可保留选择后重试");
}
domainServers.value = { ...domainServers.value, [id]: result.items };
dnsAccount.cancel();
showForm.value = false;
notice.value = createdInForm ? "域名已添加,等待申请证书" : "域名配置已更新";
try {
@ -225,7 +252,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
} catch (err) {
if (currentVersion === version) {
const prefix = !savedPayload ? "" : savedPayload === serialized
? "域名资料已保存;服务器关联尚未确认。"
? `域名资料已保存;${saveStage}尚未确认。`
: "此前域名资料已保存;本次修改尚未完成。";
formError.value = prefix + (err.message || "保存域名失败");
}
@ -235,6 +262,14 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
}
return {
dnsAccountID: dnsAccount.dnsAccountID,
dnsBindingLoading: dnsAccount.dnsBindingLoading,
dnsBindingReady: dnsAccount.dnsBindingReady,
dnsBindingError: dnsAccount.dnsBindingError,
dnsBound: dnsAccount.dnsBound,
domainDNSAccountOptions: dnsAccount.domainDNSAccountOptions,
dnsUnavailableSelection: dnsAccount.dnsUnavailableSelection,
reloadDomainDNS: dnsAccount.reloadDomainDNS,
form, editingId, saving, showForm, formError, isEditing, formTitle,
domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain,
openCreate, openEdit, closeForm, saveDomain, reloadDomainBindings,

View File

@ -22,12 +22,18 @@ async function mountForm(t, fetcher) {
notice: ref(""),
loadData: t.mock.fn(async () => {}),
};
const mounted = await mountComposable(t, () => useDomainForm(deps), fetcher);
const mounted = await mountComposable(t, () => useDomainForm(deps), (path, options) => {
if (path === "/api/v1/dns/accounts") return Promise.resolve(Response.json({ items: [] }));
if (path.endsWith("/dns-account")) return Promise.resolve(Response.json({
id: "", dns_account_id: "", domain_id: path.split("/")[4], project_id: "default",
}));
return fetcher(path, options);
});
return { ...mounted, deps };
}
function startCreate(state) {
state.openCreate();
async function startCreate(state) {
await state.openCreate();
state.form.value.name = firstDomain.name;
state.form.value.server_ids = [firstServer.id, secondServer.id];
}
@ -39,7 +45,7 @@ test("HTTP 验证保存方式且不携带 DNS 服务商", async (t) => {
if (path.endsWith("/servers")) return Response.json({ items: [] });
return Response.json({ ...firstDomain, ...JSON.parse(options.body) });
});
startCreate(state);
await startCreate(state);
state.form.value.challenge_type = "http-01";
state.form.value.dns_provider = "aliyun";
await state.saveDomain();
@ -51,7 +57,7 @@ test("HTTP 验证拒绝主泛域名和附加泛域名,不提交请求", async
let calls = 0;
const { state } = await mountForm(t, async () => { calls += 1; return Response.json({}); });
for (const type of ["wildcard", "san"]) {
startCreate(state);
await startCreate(state);
state.form.value.challenge_type = "http-01";
state.form.value.certificate_type = type;
state.form.value.sans = "*.example.com";
@ -80,7 +86,7 @@ test("创建成功但关联冲突后只重试关联,不重复创建且不改
? Response.json({ error: "共享路径冲突" }, { status: 409 })
: Response.json({ items: [firstServer, secondServer] });
});
startCreate(state);
await startCreate(state);
await state.saveDomain();
assert.equal(state.editingId.value, firstDomain.id);
assert.equal(state.showForm.value, true);
@ -106,7 +112,7 @@ test("部分成功后修改资料使用已有标识更新,取消也保留已
if (path.endsWith("/servers")) return Response.json({ error: "关联未保存" }, { status: 403 });
return Response.json({ ...firstDomain, ...JSON.parse(options.body) });
});
startCreate(state);
await startCreate(state);
await state.saveDomain();
state.form.value.auto_renew = false;
await state.saveDomain();
@ -142,7 +148,7 @@ test("编辑先读取真实关联,明确清空只提交当前域名", async (t
test("服务器按域名项目过滤,不限制其他域名共享,缺归属不默认为默认项目", async (t) => {
const { state, deps } = await mountForm(t, async () => Response.json({ items: [] }));
deps.servers.value.push({ id: "unknown" });
startCreate(state);
await startCreate(state);
assert.deepEqual(state.domainServerOptions.value.map((server) => server.id), [firstServer.id, secondServer.id]);
await state.openEdit({ ...firstDomain, project_id: "other" });
assert.deepEqual(state.domainServerOptions.value, [otherServer]);
@ -201,7 +207,7 @@ test("保存期间重复点击、关闭和切换表单不创建额外请求", as
calls += 1;
return path.endsWith("/servers") ? Response.json({ items: [firstServer, secondServer] }) : pending.promise;
});
startCreate(state);
await startCreate(state);
const saving = state.saveDomain();
await state.saveDomain();
state.openCreate();
@ -224,7 +230,7 @@ for (const failure of ["network", "server", "malformed", "missing-project"]) {
if (failure === "server") return Response.json({}, { status: 500 });
return Response.json(failure === "missing-project" ? { id: firstDomain.id } : {});
});
startCreate(state);
await startCreate(state);
await state.saveDomain();
assert.equal(state.domainSaveUncertain.value, true);
assert.match(state.formError.value, /刷新列表确认/);
@ -240,7 +246,7 @@ test("卸载后不继续保存关联,不改写列表", async (t) => {
calls += 1;
return pending.promise;
});
startCreate(state);
await startCreate(state);
const saving = state.saveDomain();
unmount();
pending.resolve(Response.json(firstDomain));
@ -258,7 +264,7 @@ test("关联网络失败可重试,但刷新失败不能被误报为保存失
return Response.json({ items: [firstServer, secondServer] });
});
deps.loadData.mock.mockImplementation(async () => { throw new Error("offline"); });
startCreate(state);
await startCreate(state);
await state.saveDomain();
assert.match(state.formError.value, /关联保存结果未确认/);
assert.equal(state.domainSaveUncertain.value, false);

View File

@ -24,6 +24,7 @@
.domain-modal .form-grid,
.domain-modal .switch-field,
.domain-modal .server-picker,
.domain-modal .domain-dns-status,
.domain-modal .form-error,
.domain-modal .modal-actions {
grid-column: 1 / -1;
@ -42,6 +43,20 @@
padding: 8px 11px;
}
.domain-modal .form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.domain-modal .field select {
width: 100%;
text-overflow: ellipsis;
}
.domain-modal .domain-dns-status {
margin: 0 0 8px;
color: #52657d;
}
.domain-modal .field textarea {
min-height: 72px;
}

View File

@ -35,6 +35,14 @@ async function renderModal(overrides = {}) {
server_ids: ["server-one", "server-two"],
},
dnsProviders: [],
dnsAccountID: "",
dnsBindingLoading: false,
dnsBindingReady: true,
dnsBindingError: "",
dnsBound: false,
domainDNSAccountOptions: [],
dnsUnavailableSelection: false,
reloadDomainDNS() {},
domainServerOptions: [
{ id: "server-one", name: "共享服务器一", address: "192.0.2.1", web_server: "nginx" },
{ id: "server-two", name: "共享服务器二", address: "192.0.2.2", web_server: "nginx" },
@ -73,6 +81,31 @@ test("域名表单使用复选框同时显示两台已关联服务器并提供
assert.match(html, /当前域名所属项目/);
});
test("域名账户显示归属与服务商,原绑定不可用不被静默清空", async () => {
const html = await renderModal({
dnsAccountID: "old-account", dnsBound: true, dnsUnavailableSelection: true,
domainDNSAccountOptions: [{ id: "new-account", name: "生产账户", provider_name: "阿里云 DNS", owner_username: "甲用户" }],
});
assert.match(html, /当前绑定账户不可用/);
assert.match(html, /生产账户 · 阿里云 DNS · 甲用户/);
assert.match(html, /解除独立绑定/);
assert.doesNotMatch(html, /id="dns-provider"/);
const challenge = html.match(/<select\b[^>]*id="challenge-type"[^>]*>/)?.[0];
assert.match(challenge, /disabled/);
});
test("DNS 绑定读取中或失败时禁止提交并提供重新读取", async () => {
for (const overrides of [
{ dnsBindingLoading: true, dnsBindingReady: false },
{ dnsBindingReady: false, dnsBindingError: "绑定读取失败" },
]) {
const html = await renderModal(overrides);
const submit = html.match(/<button\b[^>]*type="submit"[^>]*>/)?.[0];
assert.match(submit, /disabled/);
assert.match(html, /重新读取 DNS 绑定/);
}
});
test("关联读取失败或结果未确认时禁用提交,读取失败提供重试入口", async () => {
for (const overrides of [
{ domainBindingsReady: false },

View File

@ -9,6 +9,14 @@ const {
saveDomain,
form,
dnsProviders,
dnsAccountID,
dnsBindingLoading,
dnsBindingReady,
dnsBindingError,
dnsBound,
domainDNSAccountOptions,
dnsUnavailableSelection,
reloadDomainDNS,
domainServerOptions,
domainBindingsLoading,
domainBindingsReady,
@ -102,14 +110,29 @@ const {
id="challenge-type"
name="challenge_type"
v-model="form.challenge_type"
:disabled="saving || domainSaveUncertain"
:disabled="saving || domainSaveUncertain || dnsBound || Boolean(dnsAccountID) || !dnsBindingReady"
>
<option value="dns-01">DNS-01(DNS 验证)</option>
<option value="http-01">HTTP-01(网站验证)</option>
</select>
</label>
<label v-if="form.challenge_type !== 'http-01'" class="field" for="dns-provider"
><span>DNS 验证服务商</span
<label v-if="form.challenge_type !== 'http-01'" class="field" for="domain-dns-account">
<span>DNS 账户 <HelpTip>只可选择本项目内已启用且验证通过的账户。更换账户会校验当前绑定和账户版本;解除后保存,再切换验证方式。历史系统配置尚未导入独立账户。</HelpTip></span>
<select
id="domain-dns-account"
name="dns_account_id"
v-model="dnsAccountID"
:disabled="saving || domainSaveUncertain || dnsBindingLoading || !dnsBindingReady"
>
<option value="">{{ dnsBound ? "解除独立绑定(恢复历史配置)" : "历史系统配置 / 暂不绑定" }}</option>
<option v-if="dnsUnavailableSelection" :value="dnsAccountID" disabled>当前绑定账户不可用(可保留或更换)</option>
<option v-for="account in domainDNSAccountOptions" :key="account.id" :value="account.id">
{{ account.name }} · {{ account.provider_name || account.provider }} · {{ account.owner_username }}
</option>
</select>
</label>
<label v-if="form.challenge_type !== 'http-01' && !dnsAccountID && !dnsBound" class="field" for="dns-provider"
><span>历史 DNS 服务商 <HelpTip>仅兼容尚未迁移的系统配置;独立账户选择后由账户决定服务商。</HelpTip></span
><select
id="dns-provider"
name="dns_provider"
@ -127,6 +150,8 @@ const {
</select></label
>
</div>
<p v-if="dnsBindingLoading" class="domain-dns-status" role="status">正在读取 DNS 账户和现有绑定……</p>
<p v-else-if="dnsBindingError" class="form-error" role="alert">{{ dnsBindingError }}</p>
<label class="switch-field" for="auto-renew"
><input
id="auto-renew"
@ -173,6 +198,15 @@ const {
</fieldset>
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
<div class="modal-actions">
<button
v-if="!dnsBindingReady"
class="secondary-button"
type="button"
:disabled="saving || dnsBindingLoading || domainSaveUncertain"
@click="reloadDomainDNS"
>
重新读取 DNS 绑定
</button>
<button
v-if="!domainBindingsReady"
class="secondary-button"
@ -192,7 +226,7 @@ const {
><button
class="primary-button"
type="submit"
:disabled="saving || domainBindingsLoading || !domainBindingsReady || domainSaveUncertain"
:disabled="saving || domainBindingsLoading || !domainBindingsReady || domainSaveUncertain || dnsBindingLoading || !dnsBindingReady"
>
{{ saving ? "保存中……" : "保存配置" }}
</button>