feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)

评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补
  list_domains 与 get_account 余额
- NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到
  到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由
- base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商
  把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、
  腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本
  连同 MDN 链接弹给用户

动作层与 DNS
- 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计
- 新增 Cloudflare DNS 记录的增删改查

定时同步
- scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30)
- 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日
- sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期
  会显示「成功 1」,用户以为数据拉到了、实际一条没有

数据一致性
- AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染,
  缺了则所有按钮永远出不来
- import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步
  把所有云端资产当新增再建一份
- _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未
  实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量
  补标签不覆盖,用户自建平台不动

前端如实展示
- cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回
  价格,而汇总处又把 0 当「无费用」排除,两处口径不一致
- 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来
- DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底,
  否则 Cloudflare 代理勾选框永远不出现

测试:257 -> 309 passed
This commit is contained in:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+178 -1
View File
@@ -28,6 +28,10 @@ const store = reactive({
assets: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果
allAssets: [], // 统计口径:不带筛选的全量资产(总览/监控卡片/平台计数用)
providers: [],
// 适配器能力矩阵(/providers/adapters):{adapters, supported_types, capabilities}
// 前端据此如实展示「这个平台到底能不能同步/能不能启停/能不能管 DNS」,
// 而不是只看 Provider.sdk_type 有没有值就宣称支持
adapters: { adapters: [], supported_types: [], capabilities: {} },
accounts: [],
subdomains: [],
siteCerts: [],
@@ -134,6 +138,7 @@ async function loadAssets() {
store.assets = await Api.get('/assets?' + params.toString());
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadAdapters() { store.adapters = await Api.get('/providers/adapters'); }
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
async function loadCredentials() {
const params = new URLSearchParams();
@@ -147,7 +152,7 @@ async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs');
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadAll() {
store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
try { await Promise.all([loadAssets(), loadProviders(), loadAdapters(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
@@ -523,6 +528,178 @@ async function viewServer(a) {
}
}
/* ---------------- 云端能力查询 ----------------
* 能力矩阵来自后端 /providers/adapters(registry.sdk_capabilities()),
* 是「适配器真的实现了这个方法」的事实源。前端一律以它为准,
* 不再拿 Provider.sdk_type 有没有值当支持依据——填了不存在的 sdk_type
* 会让按钮可点,用户点了才收到 400。 */
function sdkCaps(sdkType) {
if (!sdkType) return {};
return (store.adapters.capabilities || {})[sdkType] || {};
}
function providerSdkType(slug) {
const p = store.providers.find(x => x.slug === slug || x.name === slug);
return p ? (p.sdk_type || null) : null;
}
/** 某平台是否声明了可用适配器(如实展示「未对接」而不是显示一个假的 sdk_type) */
function providerIsSupported(slug) {
const t = providerSdkType(slug);
return !!t && (store.adapters.supported_types || []).includes(t);
}
/** 平台在某能力上是否可用,如 capsOf('aliyun','list_vps') */
function capsOf(slug, cap) {
return !!sdkCaps(providerSdkType(slug))[cap];
}
/** 资产 -> 所属平台的 sdk_type(优先 provider_id,回退平台名/slug) */
function assetSdkType(a) {
if (!a) return null;
const p = a.provider_id
? store.providers.find(x => x.id === a.provider_id)
: store.providers.find(x => x.slug === a.provider || x.name === a.provider || x.slug === a.provider_name);
return p ? (p.sdk_type || null) : null;
}
/** 资产在某能力上是否可用;没配适配器的平台一律 false,按钮不渲染 */
function assetCan(a, cap) {
return !!sdkCaps(assetSdkType(a))[cap];
}
const INSTANCE_CAPS = ['start_instance', 'stop_instance', 'reboot_instance', 'renew_instance'];
/** VPS 是否可远程操作(任一实例动作可用即可) */
function assetCanControl(a) {
return a && a.asset_type === 'vps' && !!a.external_id && INSTANCE_CAPS.some(c => assetCan(a, c));
}
/** 域名是否可管 DNS 解析 */
function assetCanDns(a) {
return a && a.asset_type === 'domain' && assetCan(a, 'list_dns_records');
}
/* ---------------- 云资源动作(启停/重启/续费) ----------------
* 这些动作会真实改变云端资源:关机会中断线上服务、续费会真实扣款。
* 因此一律走二次确认弹窗,且按钮可用性由后端 /actions/{id}/capabilities 决定。 */
const ACTION_LABELS = { start: '开机', stop: '关机', reboot: '重启', renew: '续费' };
const ACTION_WARNS = {
start: '将向云端下发开机指令。',
stop: '将向云端下发关机指令,机器上运行的服务会立即中断。',
reboot: '将向云端下发重启指令,机器会短暂不可用。',
renew: '将向云端下发续费指令,会产生真实扣款。',
};
const actionModal = reactive({
show: false, asset: null, caps: null, loading: true, busy: false,
confirm: null, // 待二次确认的动作名
periodMonths: 1,
error: '',
});
function openActions(asset) {
actionModal.asset = asset;
actionModal.caps = null;
actionModal.loading = true;
actionModal.confirm = null;
actionModal.periodMonths = 1;
actionModal.error = '';
actionModal.show = true;
Api.get('/actions/' + asset.id + '/capabilities')
.then(r => { actionModal.caps = r; })
.catch(e => { actionModal.error = e.message; })
.finally(() => { actionModal.loading = false; });
}
function closeActions() { actionModal.show = false; actionModal.confirm = null; }
function askAction(name) { actionModal.confirm = name; }
async function runAction(name) {
if (actionModal.busy) return;
actionModal.busy = true; actionModal.error = '';
try {
const body = { action: name };
if (name === 'renew') body.period_months = actionModal.periodMonths;
const r = await Api.post('/actions/' + actionModal.asset.id + '/instance', body);
if (r.ok) {
showToast('✓ ' + ACTION_LABELS[name] + '指令已下发');
actionModal.confirm = null;
await loadAllAssets();
if (store.view === 'assets') await loadAssets();
} else {
actionModal.error = r.message || '云端拒绝了该操作';
}
} catch (e) { actionModal.error = e.message; }
finally { actionModal.busy = false; }
}
/* ---------------- DNS 记录管理 ---------------- */
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'];
const dnsModal = reactive({
show: false, asset: null, zoneId: '', provider: '',
records: [], loading: true, busy: false, error: '',
editing: null, // null=关闭表单 / 'new'=新建 / record 对象=编辑
form: null,
});
function openDns(asset) {
dnsModal.asset = asset;
dnsModal.records = [];
dnsModal.loading = true;
dnsModal.error = '';
dnsModal.editing = null;
// 先用资产自带的平台 slug 兜底:列表请求失败时 provider 拿不到后端返回值,
// 而 isCloudflare 靠它决定是否显示「走 Cloudflare 代理」勾选框。
dnsModal.provider = asset.provider || '';
dnsModal.show = true;
Api.get('/actions/' + asset.id + '/dns')
.then(r => { dnsModal.records = r.records || []; dnsModal.zoneId = r.zone_id; dnsModal.provider = r.provider; })
.catch(e => { dnsModal.error = e.message; })
.finally(() => { dnsModal.loading = false; });
}
function closeDns() { dnsModal.show = false; dnsModal.editing = null; }
function reloadDns() {
dnsModal.loading = true; dnsModal.error = '';
Api.get('/actions/' + dnsModal.asset.id + '/dns')
.then(r => { dnsModal.records = r.records || []; })
.catch(e => { dnsModal.error = e.message; })
.finally(() => { dnsModal.loading = false; });
}
function newDnsRecord() {
dnsModal.form = { name: '', type: 'A', content: '', ttl: null, priority: null, proxied: null, comment: '' };
dnsModal.editing = 'new';
}
function editDnsRecord(r) {
dnsModal.form = {
name: r.name || '', type: r.type || 'A', content: r.content || '',
ttl: r.ttl || null, priority: r.priority ?? null, proxied: r.proxied ?? null, comment: r.comment || '',
};
dnsModal.editing = r;
}
function cancelDnsEdit() { dnsModal.editing = null; dnsModal.form = null; }
async function saveDnsRecord() {
if (dnsModal.busy) return;
dnsModal.busy = true; dnsModal.error = '';
const isNew = dnsModal.editing === 'new';
const body = { ...dnsModal.form };
// 空字符串会被后端的 ge/le 校验拒掉,未填的可选字段一律不发
if (body.ttl === '' || body.ttl === null) delete body.ttl;
if (body.priority === '' || body.priority === null) delete body.priority;
if (body.proxied === null || body.proxied === '') delete body.proxied;
if (!body.comment) delete body.comment;
try {
const r = isNew
? await Api.post('/actions/' + dnsModal.asset.id + '/dns', body)
: await Api.put('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(dnsModal.editing.record_id), body);
if (r.ok) { showToast('✓ 记录已' + (isNew ? '创建' : '更新')); cancelDnsEdit(); reloadDns(); }
else dnsModal.error = r.message || '操作被拒绝';
} catch (e) { dnsModal.error = e.message; }
finally { dnsModal.busy = false; }
}
async function deleteDnsRecord(r) {
// 删解析会立刻影响线上访问,必须二次确认而不是直接删
if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return;
if (dnsModal.busy) return;
dnsModal.busy = true; dnsModal.error = '';
try {
const res = await Api.del('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(r.record_id));
if (res.ok) { showToast('✓ 记录已删除'); reloadDns(); }
else dnsModal.error = res.message || '删除被拒绝';
} catch (e) { dnsModal.error = e.message; }
finally { dnsModal.busy = false; }
}
/* ---------------- 设置 ---------------- */
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }