diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index a372ed7..0ee6256 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -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` 三轮通过。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 6263dab..d8e057b 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -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 磁盘故障终态、绑定复跑和页面开发 ### 第一台绑定修复实库结果 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index df03d26..2028de0 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,11 @@ # Scert 开发任务清单 +## 2026年10月4日06:51 开发与复审进展 + +- [x] 独立 DNS 账户选择已接入域名新建和编辑,显示服务商及归属;显式解除、实时绑定读取、版本冲突、跨项目过滤和部分成功恢复完成编码。 +- [✓] 前端新增 26 项、全量 336 项及构建通过;第一台 `412c51f` 后端全量、vet 和单轮三库目录及接口竞态均结束通过。 +- [~] 执行租约、签发续期重试、旧账户导入、安全删除回收与实际浏览器验收尚未完成;完整开发和生产验收仍未完成,不发布过渡功能为最终版本。 + ## 2026年10月4日06:28 DNS 页面开发进度 - [x] 独立账户页面生命周期及项目权限提示已接入,表格内滚动、原生弹窗和双列字段;凭据无持久化及回填,写入冲突不自动重试。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index c544a18..8461239 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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 数据,日志保留。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 3166211..9f948eb 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -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 数据目录,保留日志和生产数据,不改写失败记录。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index d43c438..9319866 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -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 管理员为真;这是界面提示,不替代每次写入的实时授权。 diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index c85c609..e6e6e64 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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]; diff --git a/frontend/src/views/Dashboard/DomainDNSAccount.js b/frontend/src/views/Dashboard/DomainDNSAccount.js new file mode 100644 index 0000000..ef66e16 --- /dev/null +++ b/frontend/src/views/Dashboard/DomainDNSAccount.js @@ -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, + }; +} diff --git a/frontend/src/views/Dashboard/DomainDNSAccount.test.js b/frontend/src/views/Dashboard/DomainDNSAccount.test.js new file mode 100644 index 0000000..17d3b8c --- /dev/null +++ b/frontend/src/views/Dashboard/DomainDNSAccount.test.js @@ -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); +}); diff --git a/frontend/src/views/Dashboard/DomainForm.js b/frontend/src/views/Dashboard/DomainForm.js index abc180c..895a13d 100644 --- a/frontend/src/views/Dashboard/DomainForm.js +++ b/frontend/src/views/Dashboard/DomainForm.js @@ -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, diff --git a/frontend/src/views/Dashboard/DomainForm.test.js b/frontend/src/views/Dashboard/DomainForm.test.js index bb22141..7b1988c 100644 --- a/frontend/src/views/Dashboard/DomainForm.test.js +++ b/frontend/src/views/Dashboard/DomainForm.test.js @@ -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); diff --git a/frontend/src/views/Dashboard/DomainModal.css b/frontend/src/views/Dashboard/DomainModal.css index e4f9d8b..87f1c5b 100644 --- a/frontend/src/views/Dashboard/DomainModal.css +++ b/frontend/src/views/Dashboard/DomainModal.css @@ -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; } diff --git a/frontend/src/views/Dashboard/DomainModal.test.js b/frontend/src/views/Dashboard/DomainModal.test.js index 57c4b83..ffc4645 100644 --- a/frontend/src/views/Dashboard/DomainModal.test.js +++ b/frontend/src/views/Dashboard/DomainModal.test.js @@ -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(/ - + +
正在读取 DNS 账户和现有绑定……
+{{ dnsBindingError }}